// Package proxy passes each request to the app and the app's answer back, // unchanged, within the size and time limits, and writes one request log // line for each request. package proxy import ( "io" "log" "log/slog" "net/http" "time" "sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/requestlog" ) // The request line and headers a client may send, and how long a // kept-open client connection may wait for its next request, are fixed // rather than settings. The limit on the request line and headers is // 32 KiB, but Go's server reads 4 KiB past its MaxHeaderBytes before it // refuses, so MaxHeaderBytes is set 4 KiB lower. The idle time is longer // than the 90 seconds after which traefik closes a connection it is not // using, so traefik never sends a request on a connection smallwebwaf is // closing. const ( requestHeaderMaxBytes = 32<<10 - 4<<10 clientIdleTimeout = 120 * time.Second ) // How smallwebwaf keeps connections to the app open between requests. const ( appIdleConns = 100 appIdleConnTimeout = 90 * time.Second ) // HealthPath is smallwebwaf's health endpoint, which the container's // health check asks. const HealthPath = "/_smallwebwaf/healthz" // Params are what New needs. type Params struct { Config *config.Config // RequestLog receives one JSON line per request. RequestLog io.Writer // ProcessLog receives the process's own messages. ProcessLog *slog.Logger // GeoJSURL is where clients' countries are looked up, normally // lookup.URL. GeoJS is asked only while a country list is set. GeoJSURL string } // New returns the server smallwebwaf runs: each request it reads passes // through the proxy. Go's server itself refuses headers over 32 KiB, with // 431, closes a connection idle for 120 seconds, and applies // SWWAF_CLIENT_REQUEST_TIMEOUT while the headers arrive; the proxy // applies the timeouts and size limits from then on. func New(params Params) *http.Server { errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn) return &http.Server{ Addr: params.Config.ListenAddr, Handler: &handler{ config: params.Config, requestLog: params.RequestLog, processLog: params.ProcessLog, errorLog: errorLog, transport: newTransport(), limiter: ratelimit.New(ratelimit.Limits{ PerMinute: params.Config.RateLimitPerMinute, PerHour: params.Config.RateLimitPerHour, PerDay: params.Config.RateLimitPerDay, }), geojs: lookup.New(lookup.Params{ URL: params.GeoJSURL, Now: time.Now, ProcessLog: params.ProcessLog, }), }, ReadHeaderTimeout: params.Config.ClientRequestTimeout, IdleTimeout: clientIdleTimeout, MaxHeaderBytes: requestHeaderMaxBytes, ErrorLog: errorLog, } } // handler is the proxy. It holds what every request shares; what belongs // to one request is in a request. type handler struct { config *config.Config requestLog io.Writer processLog *slog.Logger errorLog *log.Logger transport http.RoundTripper limiter *ratelimit.Limiter geojs *lookup.GeoJS } // newTransport returns what carries requests to the app. It never goes // through a proxy named in the environment, and leaves the app's answers // compressed or not as the app sent them. func newTransport() *http.Transport { return &http.Transport{ MaxIdleConns: appIdleConns, MaxIdleConnsPerHost: appIdleConns, IdleConnTimeout: appIdleConnTimeout, DisableCompression: true, } } // ServeHTTP handles one request: it works out the client, runs the // checks, passes the request to the app and the answer back within the // limits, and writes the request's log line. func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { rq := h.newRequest(w, r) defer rq.finish() // The health endpoint is answered at once, before any check, so that // a health checker is never refused. It does not ask the app. if r.Method == http.MethodGet && r.URL.Path == HealthPath { rq.line.Action = requestlog.ActionAdmin _, _ = io.WriteString(rq.out, "ok\n") return } refused := rq.check(r.Context()) if refused != nil { rq.answer(*refused) return } rq.forward(r.Context()) }