// 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" ) // 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 a request line and // headers over SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES, with 431, closes a // connection idle for SWWAF_CLIENT_IDLE_TIMEOUT, 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, // Off is an IdleTimeout of 0, which Go's server replaces with // ReadTimeout: no limit, as long as ReadTimeout stays unset. IdleTimeout: params.Config.ClientIdleTimeout, // Go's server reads 4 KiB past MaxHeaderBytes before it refuses, // so the limit a client meets is the setting. MaxHeaderBytes: int(params.Config.ClientRequestHeaderMaxBytes - 4<<10), 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()) }