rproxy and servewaf replace the one-process smallwebwaf (closes #172, closes #165)
check / check (push) Failing after 14m44s
check / check (push) Failing after 14m44s
`smallwebwaf` with no command now prints the commands and exits with status 2. The image runs rproxy, or servewaf with SMALLWEBWAF_COMMAND=servewaf. The passing-on code, its settings and its log fields and metric go. internal/proxy keeps the checks, answers, reports and own endpoints; its tests run through the real rproxy. The documents describe the pair. Judgement call: clients.json keeps token_refused, since state files refuse unknown fields. Deviation: script/gitea-requests sets SWWAF_LOG_REQUEST_HEADERS, to label requests. Partially verified: make example-app and make gitea-requests never built the image, as Ubuntu's snapshot service answered 5xx. Model: opus-5-5
This commit was merged in pull request #184.
This commit is contained in:
+43
-148
@@ -1,16 +1,15 @@
|
||||
// Package proxy runs the checks on each request and writes its request log
|
||||
// line. New passes each request it lets through to the app and the app's
|
||||
// answer back, unchanged, within the size and time limits. NewServeWAF instead
|
||||
// answers rproxy's question about each request, and takes rproxy's report once
|
||||
// the visitor's answer has ended, which writes the request a second log line.
|
||||
// Package proxy is servewaf's server. It runs the checks on the request each of
|
||||
// rproxy's questions carries, and answers rproxy what to do with that request. It
|
||||
// takes rproxy's report once the visitor's answer has ended, which counts the
|
||||
// request's bytes and writes its last log line. It answers servewaf's own
|
||||
// endpoints: the health check, the metrics, the bans and the clients. It passes
|
||||
// nothing to an app: internal/rproxy does that.
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
@@ -27,18 +26,12 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// adminPrefix starts the path of each of servewaf's own endpoints. The app never
|
||||
// sees a path under it.
|
||||
const adminPrefix = "/_smallwebwaf/"
|
||||
|
||||
// HealthPath is smallwebwaf's health endpoint, which the container's
|
||||
// health check asks.
|
||||
// HealthPath is the health endpoint of servewaf and of rproxy, which the
|
||||
// container's health check asks.
|
||||
const HealthPath = "/_smallwebwaf/healthz"
|
||||
|
||||
// MetricsPath is where the metrics are, for a request that carries
|
||||
@@ -49,15 +42,15 @@ const MetricsPath = "/_smallwebwaf/metrics"
|
||||
// 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.
|
||||
// ClientsPath is where an admin asks what servewaf knows of a client, by the
|
||||
// client's address after it, with SWWAF_ADMIN_TOKEN.
|
||||
const ClientsPath = "/_smallwebwaf/clients/"
|
||||
|
||||
// Params are what New needs.
|
||||
// Params are what NewServeWAF needs.
|
||||
type Params struct {
|
||||
Config *config.Config
|
||||
// RequestLog receives one JSON line per request, and from servewaf a second
|
||||
// when the request's report comes.
|
||||
// RequestLog receives two JSON lines per question, one at its decision and
|
||||
// one when its report comes, and one per request to servewaf's own endpoints.
|
||||
RequestLog io.Writer
|
||||
// ProcessLog receives the process's own messages.
|
||||
ProcessLog *slog.Logger
|
||||
@@ -77,7 +70,7 @@ type Params struct {
|
||||
// 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
|
||||
// Alerts receive the alert for each ban servewaf makes or makes
|
||||
// permanent, for each count over an anomaly threshold, for each request
|
||||
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
|
||||
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
|
||||
@@ -87,11 +80,10 @@ type Params struct {
|
||||
Alerts *alerts.Queue
|
||||
}
|
||||
|
||||
// Server is the server smallwebwaf runs, with the parts of the proxy
|
||||
// whose state the state files keep, the lookup database, nil unless
|
||||
// SWWAF_LOOKUP_SOURCE is file, the lists fetched from URLs, which its Run
|
||||
// fetches, the DNSBL zones' verdicts, AbuseIPDB's scores and checks
|
||||
// spent, and the metrics.
|
||||
// Server is the server servewaf runs, with the parts whose state the state
|
||||
// files keep, the lookup database, nil unless SWWAF_LOOKUP_SOURCE is file, the
|
||||
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
|
||||
// AbuseIPDB's scores and checks spent, and the metrics.
|
||||
type Server struct {
|
||||
*http.Server
|
||||
|
||||
@@ -106,41 +98,24 @@ type Server struct {
|
||||
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 {
|
||||
h := newHandler(params)
|
||||
|
||||
return h.server(&http.Server{
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
// newHandler returns the handler with what every request shares, as the
|
||||
// settings in params make it.
|
||||
func newHandler(params Params) *handler {
|
||||
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
||||
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
||||
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
||||
|
||||
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
|
||||
if err != nil {
|
||||
panic(err) // New fails only for a size below one
|
||||
}
|
||||
|
||||
h := &handler{
|
||||
config: params.Config,
|
||||
requestLog: params.RequestLog,
|
||||
processLog: params.ProcessLog,
|
||||
errorLog: errorLog,
|
||||
transport: newTransport(),
|
||||
now: params.Now,
|
||||
metrics: m,
|
||||
config: params.Config,
|
||||
requestLog: params.RequestLog,
|
||||
processLog: params.ProcessLog,
|
||||
now: params.Now,
|
||||
metrics: m,
|
||||
awaitingReport: awaitingReport,
|
||||
limiter: ratelimit.New(ratelimit.Limits{
|
||||
PerMinute: params.Config.RateLimitPerMinute,
|
||||
PerHour: params.Config.RateLimitPerHour,
|
||||
@@ -232,7 +207,10 @@ func newReputation(
|
||||
|
||||
// newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL,
|
||||
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies
|
||||
// up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off.
|
||||
// up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off. It reads at
|
||||
// most QuestionBodyMaxBytes less one byte: a question's body of
|
||||
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
||||
// JSON or XML body over its limit.
|
||||
func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
|
||||
if cfg.WAFMode == config.WAFModeOff {
|
||||
return nil
|
||||
@@ -240,26 +218,24 @@ func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
|
||||
|
||||
coreRuleSet, err := waf.New(waf.Params{
|
||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||
BodyLimit: cfg.WAFBodyLimit,
|
||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||
})
|
||||
if err != nil {
|
||||
// The Core Rule Set is built in, and the settings cannot break it:
|
||||
// the paranoia level is from 1 to 4, the body limit at most 1G, and
|
||||
// the id of no rule switches nothing off.
|
||||
// the paranoia level is from 1 to 4, and the id of no rule switches
|
||||
// nothing off.
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return coreRuleSet
|
||||
}
|
||||
|
||||
// handler is the proxy. It holds what every request shares; what belongs
|
||||
// to one request is in a request.
|
||||
// handler is servewaf's handler. 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
|
||||
@@ -273,88 +249,7 @@ type handler struct {
|
||||
rules *rules.Files
|
||||
coreRuleSet *waf.CoreRuleSet
|
||||
alerts *alerts.Queue
|
||||
// awaitingReport holds, in servewaf, the decision on each answered
|
||||
// question by its id until its report comes, and nil for a question still
|
||||
// being handled.
|
||||
// awaitingReport holds the decision on each answered question by its id
|
||||
// until its report comes, and nil for a question still being handled.
|
||||
awaitingReport *lru.Cache[string, *decision]
|
||||
}
|
||||
|
||||
// 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.answerHealth()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Once the request has ended, before its log line is written. The
|
||||
// last deferred runs first: countRefusal before addToHistory, so that
|
||||
// a broken error burst is in the client's history.
|
||||
defer rq.addToHistory()
|
||||
defer rq.countAnomalies()
|
||||
defer rq.countRefusal()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Once the response has ended, before the request is added to its
|
||||
// client's history. Deferred, since ReverseProxy panics to end a
|
||||
// response it cannot finish.
|
||||
defer rq.countBytes()
|
||||
|
||||
rq.forward(r.Context())
|
||||
}
|
||||
|
||||
// server returns the Server of h, which listens at SWWAF_LISTEN_ADDR and
|
||||
// logs through h, with what httpServer sets.
|
||||
func (h *handler) server(httpServer *http.Server) *Server {
|
||||
httpServer.Addr = h.config.ListenAddr
|
||||
httpServer.ErrorLog = h.errorLog
|
||||
|
||||
return &Server{
|
||||
Server: httpServer,
|
||||
Ledger: h.ledger,
|
||||
Limiter: h.limiter,
|
||||
GeoJS: h.geojs,
|
||||
Anomalies: h.anomalies,
|
||||
LookupFile: h.lookupFile,
|
||||
Lists: h.lists,
|
||||
DNSBL: h.dnsbl,
|
||||
AbuseIPDB: h.abuseIPDB,
|
||||
Metrics: h.metrics,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user