// 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" "strings" "time" "sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/metrics" "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 ) // adminPrefix starts the path of every request for smallwebwaf itself, // which never reaches the app. const adminPrefix = "/_smallwebwaf/" // HealthPath is smallwebwaf's health endpoint, which the container's // health check asks. const HealthPath = "/_smallwebwaf/healthz" // MetricsPath is where the metrics are, for a request that carries // SWWAF_METRICS_TOKEN. const MetricsPath = "/_smallwebwaf/metrics" // 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 // Now tells the time by which requests are counted for the rate // limits, bans are made and run out, and GeoJS's answers are kept, // normally time.Now in UTC, the time the state files give. Now func() time.Time } // Server is the server smallwebwaf runs, with the parts of the proxy // whose state the state files keep, and the metrics. type Server struct { *http.Server Ledger *bans.Ledger Limiter *ratelimit.Limiter GeoJS *lookup.GeoJS Metrics *metrics.Metrics } // 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) *Server { errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn) m := metrics.New(params.Config.MetricsTopN) h := &handler{ config: params.Config, requestLog: params.RequestLog, processLog: params.ProcessLog, errorLog: errorLog, transport: newTransport(), now: params.Now, metrics: m, limiter: ratelimit.New(ratelimit.Limits{ PerMinute: params.Config.RateLimitPerMinute, PerHour: params.Config.RateLimitPerHour, PerDay: params.Config.RateLimitPerDay, }), ledger: bans.New(bans.Rules{ LimitBanDuration: params.Config.LimitBanDuration, LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow, MaxBanDuration: params.Config.MaxBanDuration, MaxBans: params.Config.MaxBans, }), geojs: lookup.New(lookup.Params{ URL: params.GeoJSURL, Now: params.Now, ProcessLog: params.ProcessLog, Metrics: m, }), } m.AddBansAndClients(h.ledger, h.limiter, params.Now) return &Server{ Server: &http.Server{ Addr: params.Config.ListenAddr, Handler: h, 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, }, Ledger: h.ledger, Limiter: h.limiter, GeoJS: h.geojs, Metrics: m, } } // 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 now func() time.Time metrics *metrics.Metrics limiter *ratelimit.Limiter ledger *bans.Ledger 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, or answers it itself if it is for smallwebwaf, 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 } // Once the request has ended, before its log line is written. defer rq.addToHistory() refused := rq.check(r.Context()) if refused != nil { rq.answer(*refused) return } // A request for smallwebwaf itself is answered where another would be // passed to the app, so that it goes through every check first. if strings.HasPrefix(r.URL.Path, adminPrefix) { rq.answerAdmin() return } rq.forward(r.Context()) }