Files
smallwebwaf/internal/smallwebwaf/smallwebwaf.go
T
clawbot 6ec52e5b87
check / check (push) Waiting to run
Take in an admin's edits of the state files while running (closes #68)
smallwebwaf watches SWWAF_STATE_DIR with fsnotify and takes in a saved
edit of a state file in place of what it held. It knows its own writes
by the SHA-256 of what it last read or wrote; each write first takes in
an edit made since. An edit that does not parse is renamed to
<name>.bad at the next write. Each edit taken in or set aside is logged
and counted. Every ban on a netblock is checked, and the next ban is
worked out from the one that ended last. README.md says how to add and
lift a ban.

Judgement call: a broken edit is set aside at the next write, since an
editor's file can be read half written.

Model: opus-5-5
2026-10-06 14:18:13 +02:00

196 lines
5.0 KiB
Go

// Package smallwebwaf runs the smallwebwaf process: it reads the settings
// 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/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 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
}
// 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,
})
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, processLog)
}
// serve serves requests on listener, writes the state files as they are
// due, and takes in an admin's edits of them, 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, 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{})
go func() {
files.Run(writing)
close(written)
}()
go func() {
files.Watch(writing)
close(watched)
}()
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
err = files.WriteAll()
if err != nil {
processLog.Error("writing the state files failed", "error", err.Error())
return 1
}
processLog.Info("stopped")
return 0
}