Pass-through proxy with timeouts, size limits and a request log (closes #13)
check / check (push) Successful in 1m29s

Milestone 1, the repo's first code. smallwebwaf passes each request to the app and the answer back unchanged, streaming bodies and WebSocket upgrades, within four timeouts (client and app, request and response) and two size limits, and writes one JSON line per request to stdout. Every setting has an SWWAF_ name and a default, and an invalid value stops the start. The repo gets the standard layout: script/ entrypoints, make targets that call them, a Dockerfile that runs the checks, and the Gitea workflow.

Disclosure: SPEC.md changed. Go's server reads the request line and headers before smallwebwaf sees the request, so slow headers are closed without an answer, and neither slow nor oversized headers get a log line.
Disclosure: standard library only.

Model: opus-5-5
This commit was merged in pull request #39.
This commit is contained in:
2026-10-03 17:24:34 +02:00
parent fd77e76177
commit d76715b0df
47 changed files with 4917 additions and 74 deletions
+130
View File
@@ -0,0 +1,130 @@
// Package smallwebwaf runs the smallwebwaf process: it reads the settings,
// serves requests until it is told to stop, and then stops in an orderly
// way.
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/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// 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.
func Main(version string) int {
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, 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
}
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
}
server := proxy.New(proxy.Params{
Config: cfg,
RequestLog: params.Stdout,
ProcessLog: processLog,
})
processLog.Info("starting",
"version", params.Version,
"address", listener.Addr().String(),
"settings", cfg)
return serve(ctx, server, listener, processLog)
}
// serve serves requests on listener until ctx is done, then gives the
// requests in progress shutdownTimeout to finish.
func serve(
ctx context.Context, server *http.Server, listener net.Listener,
processLog *slog.Logger,
) int {
served := make(chan error, 1)
go func() {
served <- server.Serve(listener)
}()
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
}
processLog.Info("stopped")
return 0
}
+225
View File
@@ -0,0 +1,225 @@
package smallwebwaf_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
)
const (
// waitLimit bounds how long a test waits for what should happen.
waitLimit = 10 * time.Second
// pollInterval is how often a test looks for a line.
pollInterval = 10 * time.Millisecond
// testVersion is the version the tests give smallwebwaf.
testVersion = "test"
// localhost is where the tests listen.
localhost = "127.0.0.1"
listenAddr = "SWWAF_LISTEN_ADDR"
)
// output collects what smallwebwaf writes on stdout.
type output struct {
mu sync.Mutex
buf bytes.Buffer
}
// Write adds lines smallwebwaf writes.
func (o *output) Write(p []byte) (int, error) {
o.mu.Lock()
defer o.mu.Unlock()
return o.buf.Write(p)
}
// line returns the first line whose field key is value, waiting for it.
func (o *output) line(t *testing.T, key, value string) map[string]any {
t.Helper()
deadline := time.Now().Add(waitLimit)
for time.Now().Before(deadline) {
o.mu.Lock()
text := o.buf.String()
o.mu.Unlock()
for line := range strings.Lines(text) {
var fields map[string]any
err := json.Unmarshal([]byte(line), &fields)
if err != nil {
t.Fatalf("output line %q is not JSON: %v", line, err)
}
if fields[key] == value {
return fields
}
}
time.Sleep(pollInterval)
}
t.Fatalf("no line with %s %q in the output:\n%s", key, value, o.buf.String())
return nil
}
// run runs smallwebwaf with the settings in env until ctx is done, and
// returns its exit status.
func run(ctx context.Context, env map[string]string, out *output) int {
return smallwebwaf.Run(ctx, smallwebwaf.Params{
Version: testVersion,
LookupEnv: func(name string) (string, bool) {
value, ok := env[name]
return value, ok
},
Stdout: out,
})
}
func TestInvalidSettingStopsTheStart(t *testing.T) {
t.Parallel()
out := &output{}
status := run(t.Context(), map[string]string{"SWWAF_REQUEST_MAX_BYTES": "lots"}, out)
if status != 1 {
t.Errorf("exit status %d, want 1", status)
}
line := out.line(t, "msg", "invalid setting")
message, _ := line["error"].(string)
if line["type"] != "process" || line["level"] != "ERROR" ||
!strings.HasPrefix(message, "SWWAF_REQUEST_MAX_BYTES: ") {
t.Errorf("start refused with %v", line)
}
}
func TestAddressInUseStopsTheStart(t *testing.T) {
t.Parallel()
taken, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer func() {
_ = taken.Close()
}()
out := &output{}
status := run(t.Context(), map[string]string{listenAddr: taken.Addr().String()}, out)
if status != 1 {
t.Errorf("exit status %d, want 1", status)
}
out.line(t, "msg", "cannot listen on SWWAF_LISTEN_ADDR")
}
func TestServesUntilToldToStop(t *testing.T) {
t.Parallel()
app := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "hello from the app")
}))
defer app.Close()
ctx, stop := context.WithCancel(t.Context())
out := &output{}
exited := make(chan int, 1)
go func() {
exited <- run(ctx, map[string]string{
listenAddr: localhost + ":0",
"SWWAF_UPSTREAM_URL": app.URL,
}, out)
}()
starting := out.line(t, "msg", "starting")
wantStartingLine(t, starting, app.URL)
addr, _ := starting["address"].(string)
wantGreeting(t, "http://"+addr+"/")
out.line(t, "type", "request")
stop()
select {
case status := <-exited:
if status != 0 {
t.Errorf("exit status %d, want 0", status)
}
case <-time.After(waitLimit):
t.Fatal("still running after being told to stop")
}
out.line(t, "msg", "stopped")
}
// wantStartingLine checks that the line at start gives the version and
// every setting's value.
func wantStartingLine(t *testing.T, line map[string]any, appURL string) {
t.Helper()
settings, _ := line["settings"].(map[string]any)
want := map[string]any{
listenAddr: localhost + ":0",
"SWWAF_UPSTREAM_URL": appURL,
"SWWAF_TRUSTED_PROXIES": "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
"SWWAF_CLIENT_REQUEST_TIMEOUT": "60s",
"SWWAF_CLIENT_RESPONSE_TIMEOUT": "30m",
"SWWAF_UPSTREAM_REQUEST_TIMEOUT": "60s",
"SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m",
"SWWAF_REQUEST_MAX_BYTES": "100M",
"SWWAF_RESPONSE_MAX_BYTES": "5G",
}
for name, value := range want {
if settings[name] != value {
t.Errorf("starting line gives %s=%v, want %v", name, settings[name], value)
}
}
if line["version"] != testVersion || line["type"] != "process" {
t.Errorf("starting line %v", line)
}
}
// wantGreeting checks that a request to url gets the app's answer.
func wantGreeting(t *testing.T, url string) {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url,
http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
transport := &http.Transport{}
defer transport.CloseIdleConnections()
res, err := (&http.Client{Transport: transport}).Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
if err != nil || string(body) != "hello from the app" {
t.Errorf("got %q (%v), want the app's answer", body, err)
}
}