check / check (push) Waiting to run
Process log lines carry instance, as request lines do; the instance name is read before the other settings, so the line saying a setting is invalid carries it too. Every metric, Go's and the process's included, carries the label instance, set once on the registry. README.md says so, and that Prometheus keeps it as exported_instance unless the scrape sets honor_labels. An instance name that is not valid UTF-8 stops the start, as the metrics library panics on such a label. Tests that read metrics expect the label; one helper replaces the alert tests' loops that wait for them. Judgement call: the label is named instance, as in the log lines and alerts, although Prometheus gives each target a label of that name. Model: opus-5-5
216 lines
6.9 KiB
Go
216 lines
6.9 KiB
Go
// 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/alerts"
|
|
"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"
|
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
|
)
|
|
|
|
// 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"
|
|
|
|
// BansPath is where an admin lists and adds bans, and, followed by / and
|
|
// a client's address, lifts them, with SWWAF_ADMIN_TOKEN.
|
|
const BansPath = "/_smallwebwaf/bans"
|
|
|
|
// ClientsPath is where an admin asks what smallwebwaf knows of a client,
|
|
// by the client's address after it, with SWWAF_ADMIN_TOKEN.
|
|
const ClientsPath = "/_smallwebwaf/clients/"
|
|
|
|
// 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
|
|
// Rules are the rule files' rules, which each request is checked
|
|
// against.
|
|
Rules *rules.Files
|
|
// Alerts receive the alert for each ban the proxy makes or makes
|
|
// permanent, and for GeoJS failing.
|
|
Alerts *alerts.Queue
|
|
}
|
|
|
|
// 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, params.Config.InstanceName)
|
|
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,
|
|
AttackBanDuration: params.Config.AttackBanDuration,
|
|
MaxBans: params.Config.MaxBans,
|
|
}),
|
|
geojs: lookup.New(lookup.Params{
|
|
URL: params.GeoJSURL,
|
|
Now: params.Now,
|
|
ProcessLog: params.ProcessLog,
|
|
Metrics: m,
|
|
Alerts: params.Alerts,
|
|
}),
|
|
rules: params.Rules,
|
|
alerts: params.Alerts,
|
|
}
|
|
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
|
m.AddRules(params.Rules)
|
|
|
|
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
|
|
rules *rules.Files
|
|
alerts *alerts.Queue
|
|
}
|
|
|
|
// 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
|
|
// Set here rather than left to Go's server, which would set it only
|
|
// after the log line has taken the response's headers.
|
|
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = 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())
|
|
rq.checked = time.Now()
|
|
|
|
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())
|
|
}
|