check / check (push) Waiting to run
Every *.rules file in SWWAF_RULES_DIR is read at start and on each change, and each request is checked against the rules after the rate limits: log notes a match, block refuses with 403, ban refuses and bans the netblock for SWWAF_ATTACK_BAN_DURATION, made permanent by its next request or clear sign of attack. path, query and uri are matched as the request line sent them. bans.json gains each ban's cause, and ban notes count earlier bans by cause. The image ships 00-default.rules. Judgement call: a header sent twice is matched with its values joined by ", ". Judgement call: SWWAF_MAX_BAN_DURATION does not cap a ban for an attack. Not in this unit: offences for rule matches, with the error burst. Model: opus-5-5
216 lines
5.5 KiB
Go
216 lines
5.5 KiB
Go
// Package smallwebwaf runs the smallwebwaf process: it reads the settings,
|
|
// the rule files and the state files, serves requests until it is told to
|
|
// stop, and then stops in an orderly way, writing the state files.
|
|
package smallwebwaf
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
|
"sneak.berlin/go/smallwebwaf/internal/state"
|
|
)
|
|
|
|
// shutdownTimeout is how long requests in progress may take to finish
|
|
// once smallwebwaf is told to stop, before their connections are closed.
|
|
// runit and docker wait a little longer before they kill the process.
|
|
const shutdownTimeout = 5 * time.Second
|
|
|
|
// Params are what Run needs from the process.
|
|
type Params struct {
|
|
// Version is the version of the binary, set when it is built.
|
|
Version string
|
|
// LookupEnv reads an environment variable, normally os.LookupEnv.
|
|
LookupEnv func(string) (string, bool)
|
|
// Stdout receives the request log and the process's own messages.
|
|
Stdout io.Writer
|
|
}
|
|
|
|
// Main runs smallwebwaf until SIGTERM or SIGINT, and returns the
|
|
// process's exit status. Run as `smallwebwaf healthcheck`, it is the
|
|
// container's health check instead.
|
|
func Main(version string) int {
|
|
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
|
|
return HealthCheck(context.Background(), os.Args[2:], os.LookupEnv, os.Stderr)
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(),
|
|
syscall.SIGTERM, os.Interrupt)
|
|
defer stop()
|
|
|
|
return Run(ctx, Params{
|
|
Version: version,
|
|
LookupEnv: os.LookupEnv,
|
|
Stdout: os.Stdout,
|
|
})
|
|
}
|
|
|
|
// Run reads the settings, the rule files and the state files, then serves
|
|
// requests until ctx is done. It returns the process's exit status, 1
|
|
// when smallwebwaf cannot start.
|
|
func Run(ctx context.Context, params Params) int {
|
|
processLog := requestlog.NewProcessLogger(params.Stdout)
|
|
|
|
cfg, err := config.FromEnvironment(params.LookupEnv)
|
|
if err != nil {
|
|
processLog.Error("invalid setting", "error", err.Error())
|
|
|
|
return 1
|
|
}
|
|
|
|
ruleFiles, err := rules.Load(rules.Params{
|
|
Dir: cfg.RulesDir,
|
|
Enabled: cfg.RulesEnabled,
|
|
ProcessLog: processLog,
|
|
})
|
|
if err != nil {
|
|
processLog.Error("cannot use the rule files", "error", err.Error())
|
|
|
|
return 1
|
|
}
|
|
|
|
// The state files give times in UTC.
|
|
now := func() time.Time { return time.Now().UTC() }
|
|
|
|
server := proxy.New(proxy.Params{
|
|
Config: cfg,
|
|
RequestLog: params.Stdout,
|
|
ProcessLog: processLog,
|
|
GeoJSURL: lookup.URL,
|
|
Now: now,
|
|
Rules: ruleFiles,
|
|
})
|
|
|
|
files, err := state.Load(state.Params{
|
|
Dir: cfg.StateDir,
|
|
WriteDelay: cfg.StateWriteDelay,
|
|
CounterInterval: cfg.StateCounterInterval,
|
|
Ledger: server.Ledger,
|
|
Limiter: server.Limiter,
|
|
GeoJS: server.GeoJS,
|
|
Now: now,
|
|
ProcessLog: processLog,
|
|
Metrics: server.Metrics,
|
|
})
|
|
if err != nil {
|
|
processLog.Error("cannot use the state files", "error", err.Error())
|
|
|
|
return 1
|
|
}
|
|
|
|
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
|
|
if err != nil {
|
|
processLog.Error("cannot listen on SWWAF_LISTEN_ADDR",
|
|
"error", err.Error())
|
|
|
|
return 1
|
|
}
|
|
|
|
processLog.Info("starting",
|
|
"version", params.Version,
|
|
"address", listener.Addr().String(),
|
|
"settings", cfg)
|
|
|
|
return serve(ctx, server.Server, listener, files, ruleFiles, processLog)
|
|
}
|
|
|
|
// serve serves requests on listener, writes the state files as they are
|
|
// due, takes in an admin's edits of them, and reads the rule files again
|
|
// as they change, until ctx is done. Then it gives the requests in
|
|
// progress shutdownTimeout to finish, and writes every state file.
|
|
func serve(
|
|
ctx context.Context, server *http.Server, listener net.Listener,
|
|
files *state.Files, ruleFiles *rules.Files, processLog *slog.Logger,
|
|
) int {
|
|
served := make(chan error, 1)
|
|
|
|
go func() {
|
|
served <- server.Serve(listener)
|
|
}()
|
|
|
|
writing, stopWriting := context.WithCancel(ctx)
|
|
defer stopWriting()
|
|
|
|
written := make(chan struct{})
|
|
watched := make(chan struct{})
|
|
rulesWatched := make(chan struct{})
|
|
|
|
go func() {
|
|
files.Run(writing)
|
|
close(written)
|
|
}()
|
|
|
|
go func() {
|
|
files.Watch(writing)
|
|
close(watched)
|
|
}()
|
|
|
|
go func() {
|
|
ruleFiles.Watch(writing)
|
|
close(rulesWatched)
|
|
}()
|
|
|
|
select {
|
|
case err := <-served:
|
|
processLog.Error("serving failed", "error", err.Error())
|
|
|
|
return 1
|
|
case <-ctx.Done():
|
|
}
|
|
|
|
processLog.Info("stopping")
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx),
|
|
shutdownTimeout)
|
|
defer cancel()
|
|
|
|
err := server.Shutdown(shutdownCtx)
|
|
if err != nil {
|
|
processLog.Warn("requests still in progress were cut off",
|
|
"error", err.Error())
|
|
|
|
_ = server.Close()
|
|
}
|
|
|
|
err = <-served
|
|
if !errors.Is(err, http.ErrServerClosed) {
|
|
processLog.Error("serving failed", "error", err.Error())
|
|
|
|
return 1
|
|
}
|
|
|
|
// Run and Watch have ended, so nothing else reads or writes the
|
|
// files. Every request has ended too, but for two kinds
|
|
// that Go's server does not wait for: one cut off because Shutdown
|
|
// timed out, and one whose connection switched protocols, such as a
|
|
// WebSocket. Such a request adds to its client's history only as it
|
|
// ends, which can be after this write, and then that request is
|
|
// missing from clients.json.
|
|
<-written
|
|
<-watched
|
|
<-rulesWatched
|
|
|
|
err = files.WriteAll()
|
|
if err != nil {
|
|
processLog.Error("writing the state files failed", "error", err.Error())
|
|
|
|
return 1
|
|
}
|
|
|
|
processLog.Info("stopped")
|
|
|
|
return 0
|
|
}
|