Milestones 1 and 2: proxy with timeouts and size limits, rate limits, country lists and the image #40

Open
clawbot wants to merge 23 commits from next into main
Collaborator

next holds milestones 1 and 2 and the first parts of what follows them:

  • Milestone 1 (#13): a proxy in front of one app, within its timeouts and size limits, logging a JSON line per request.
  • Milestone 2 (#14): per-client rate limits; country allow and deny lists through GeoJS; and the image apps build FROM.
  • Network lists (#19), empty by default.
  • Bans (#18): a client that breaks a rate limit is banned, an hour at first, three times longer on each repeat within a day, permanent past seven days.
  • State files (#17) in /var/lib/smallwebwaf, so a restart lifts no ban; an admin's edits to them are taken in while running (#68).
  • Prometheus metrics (#23), off until SWWAF_METRICS_TOKEN is set.
  • SWWAF_MODE=observe (#78): logs what would be refused and refuses nothing, for trying a configuration first.
  • The header size and idle time as settings (#70).

To know before merging or deploying:

  • Mount a volume at /var/lib/smallwebwaf to keep state across deploys.
  • Refusals answer SWWAF_BAN_RESPONSE, 403 by default, where milestone 2 answered 429 for a rate limit. A ban is lifted or added by editing bans.json, as README.md shows.
  • No path under /_smallwebwaf/ reaches the app any more.
  • Nothing publishes the image: build it on an amd64 host (make docker), push it to a registry, and build each app on it pinned by digest, as README.md shows.

Model: opus-5-5

`next` holds milestones 1 and 2 and the first parts of what follows them: - Milestone 1 (https://git.eeqj.de/sneak/smallwebwaf/issues/13): a proxy in front of one app, within its timeouts and size limits, logging a JSON line per request. - Milestone 2 (https://git.eeqj.de/sneak/smallwebwaf/issues/14): per-client rate limits; country allow and deny lists through GeoJS; and the image apps build `FROM`. - Network lists (https://git.eeqj.de/sneak/smallwebwaf/issues/19), empty by default. - Bans (https://git.eeqj.de/sneak/smallwebwaf/issues/18): a client that breaks a rate limit is banned, an hour at first, three times longer on each repeat within a day, permanent past seven days. - State files (https://git.eeqj.de/sneak/smallwebwaf/issues/17) in `/var/lib/smallwebwaf`, so a restart lifts no ban; an admin's edits to them are taken in while running (https://git.eeqj.de/sneak/smallwebwaf/issues/68). - Prometheus metrics (https://git.eeqj.de/sneak/smallwebwaf/issues/23), off until `SWWAF_METRICS_TOKEN` is set. - `SWWAF_MODE=observe` (https://git.eeqj.de/sneak/smallwebwaf/issues/78): logs what would be refused and refuses nothing, for trying a configuration first. - The header size and idle time as settings (https://git.eeqj.de/sneak/smallwebwaf/issues/70). To know before merging or deploying: - Mount a volume at `/var/lib/smallwebwaf` to keep state across deploys. - Refusals answer `SWWAF_BAN_RESPONSE`, `403` by default, where milestone 2 answered `429` for a rate limit. A ban is lifted or added by editing `bans.json`, as `README.md` shows. - No path under `/_smallwebwaf/` reaches the app any more. - Nothing publishes the image: build it on an amd64 host (`make docker`), push it to a registry, and build each app on it pinned by digest, as `README.md` shows. Model: opus-5-5
clawbot added 1 commit 2026-10-03 17:25:15 +02:00
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
clawbot self-assigned this 2026-10-03 17:25:18 +02:00
clawbot added the needs-checks label 2026-10-03 17:25:18 +02:00
clawbot added merge-ready and removed needs-checks labels 2026-10-04 01:34:33 +02:00
clawbot removed their assignment 2026-10-04 01:34:34 +02:00
sneak was assigned by clawbot 2026-10-04 01:34:34 +02:00
clawbot added 1 commit 2026-10-04 01:42:46 +02:00
SWWAF_LISTEN_ADDR may set another port: the health check takes its port
from it, and traefik's port label must name the same one. Its address
part stays empty (:9000), so smallwebwaf keeps listening on every
address, where traefik and the health check on 127.0.0.1 both reach it.
A token file is made on the host owned by uid 65532 with mode 0400 and
its directory mounted read-only; through upaas, that directory is one of
the app's volume mounts. The run script of smallwebwaf makes the state
directory and every file in it belong to the smallwebwaf user.

Model: opus-5-5
clawbot added 1 commit 2026-10-04 02:42:59 +02:00
ca-certificates, nix-bin and runit come from a dated Ubuntu snapshot no
older than the pinned Ubuntu image. The Dockerfile names the SHA-256 hash
of each snapshot InRelease file apt uses, and the build checks them before
apt-get install, so every package is checked against hashed files. That
install uses the Go image's CA certificate file. ca-certificates is
installed by name. The image writes build-users-group = to
/etc/nix/nix.conf so root can build without a daemon. nixpkgs comes from
its release file on releases.nixos.org, checked by SHA-256, and takes about
500 MiB of disk. runsvinit is archived upstream and is built at a fixed
commit with a go.mod written for the build. The example run scripts put
their code in a main function.

Model: opus-5-5
clawbot added 1 commit 2026-10-04 02:57:26 +02:00
The header size and the idle time stay fixed at their defaults through
milestone 2 and become settings in the first stage after it, which also
brings SWWAF_ALLOW_NETS and with it the allow-only country list's refusal
of private addresses. While a request body is on its way, a request
timeout answers 408 or 504 by the side smallwebwaf was waiting on, as
milestone 1's code does. The README says the state files, the GeoJS
answers among them, come in milestone 3 or later, and points at the
build order.

Model: opus-5-5
clawbot added 1 commit 2026-10-04 03:07:53 +02:00
Add the MIT licence (closes #15)
check / check (push) Successful in 3m8s
bedd324f3c
LICENSE with the MIT text, the README naming MIT in its opening line and a
License section, and the licence field in package.json.

Model: opus-5-5
clawbot added 1 commit 2026-10-04 04:24:36 +02:00
Each client, one IPv4 address or one IPv6 /64, is counted in two buckets
per window, the earlier weighted by how much of it the window covers; at
most 20,000 clients are kept, least recently seen dropped first. A
request over SWWAF_RATE_LIMIT_PER_MINUTE, _HOUR or _DAY (1000, 10000,
50000, or off) gets 429 before reaching the app. Refused requests count,
413s included. A clock set back over a second behind a bucket's start
restarts that window. The log line gains limit_hit and the action
rate_limited.

Deviation from SPEC.md, per the issue: the 20,000 bound and /64 are fixed.
Judgement call: golang-lru/v2 holds the table; httprate does not count refused requests.
Deviation: go.mod and go.sum hand-written; no make target tidies them.

Model: opus-5-5
clawbot added 1 commit 2026-10-04 06:09:25 +02:00
In the cases where the app reads and the client stops sending halfway,
smallwebwaf waits on the client only once it has passed the first bytes
of the body to the app. A test process held up for the whole 300 ms
timeout before then rightly gets 504, as SPEC.md asks, so the test was
wrong to expect 408 every time.

The app in those cases now records whether it received any of the body.
Once the app has finished with the request, the case expects 408 and its
log line if it did, and 504 and its log line if not.

Model: opus-5-5
clawbot added 1 commit 2026-10-04 06:39:10 +02:00
Point the unreachable-app test at port 1 (closes #51)
check / check (push) Successful in 2m21s
d730fcb57d
TestAnswers502WhenTheAppCannotBeReached closed a listener and pointed
smallwebwaf at its port, which another test running in parallel could
open in between, so the test sometimes got that server's answer instead
of a 502. It now uses 127.0.0.1:1: nothing listens there, and a test
listening on port 0 is always given a port from 32768 up, so no test
can take it. No other test reuses a closed listener's port.

Model: opus-5-5
clawbot added 1 commit 2026-10-04 07:53:19 +02:00
smallwebwaf starts each timeout as the request arrives, before the step
a test needs first: the upgrade answered, the app's buffers full, the
first part of an answer passed on. A hold-up of the test process longer
than the 300 ms timeout ran it out before that step. No test can make
that step come first, and in the "waiting on the app" cases it cannot
see which side smallwebwaf was waiting on, so the timeout is now 5 s,
the hold-up wantTimedOut already allows. The timeout tests set when it
must not run out goes from 10 s to 1 m to stay clear of it. The upgrade
test waits 7.5 s past the upgrade.

Judgement call: one shared value; the proxy tests take about 8 s, not 2 s.

Model: opus-5-5
clawbot added 1 commit 2026-10-04 08:29:44 +02:00
SWWAF_DENIED_COUNTRIES and SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES refuse a
request with 403 before its body is read or rate-limited, logged as
country_denied. internal/lookup asks GeoJS only while a list is set, 200
clients per request, one at a time, keeping answers 7 days. Failures, a
redirect or an answer leaving an address out included, are logged without
addresses; GeoJS is then left alone a second, doubling to five minutes.
Private, loopback and link-local clients are never sent.

Deviation, per the issue: no SWWAF_LOOKUP_SOURCE or SWWAF_LOOKUP_TIMEOUT; 403, not SWWAF_BAN_RESPONSE.
Deviation: GeoJS's country endpoint, not geo.json.
Judgement call: an IPv6 /64 is asked about by its first address; at most 10,000 clients wait.
Judgement call: config.go lists the ISO 3166-1 codes; no widely used library holds them.

Model: opus-5-5
clawbot added 1 commit 2026-10-04 10:05:33 +02:00
The Dockerfile's last stage is now the image of "Deployment" in SPEC.md:
Ubuntu 26.04 with ca-certificates, nix-bin and runit from a dated
snapshot whose InRelease files are checked by hash, nixpkgs from its
release file checked by SHA-256, runsvinit built at a fixed commit, and
smallwebwaf as a runit service. smallwebwaf answers
/_smallwebwaf/healthz, and `smallwebwaf healthcheck`, which takes no
further argument, is the image's HEALTHCHECK. script/example-app builds
an app on the image and checks it end to end.

The Nix profile comes last on the PATH: first, busybox from nixpkgs
replaced runit's own runsvdir and sv. SPEC.md is corrected to match
what was built.

Model: opus-5-5
clawbot changed title from Milestone 1: pass-through proxy with timeouts, size limits and a request log to Milestones 1 and 2: proxy with timeouts and size limits, rate limits, country lists and the image 2026-10-04 10:05:57 +02:00
clawbot added 1 commit 2026-10-04 11:13:34 +02:00
The test phase spends most of its time compiling with the race
detector from an empty build cache; then come writing the test image
and the internal/proxy tests.

- Go's build cache is on a tmpfs in the test phase, so its 137 MB are
  no longer written into the test image.
- TestUpgradedConnectionOutlastsTheTimeouts waits until just past
  shortTimeout after the answer to the upgrade was read, by when every
  timeout has started, rather than 7.5 s, so it ends with the other
  timing tests.
- shortTimeout is written as waitLimit / 2, as its comment says it is.

Model: opus-5-5
clawbot added 1 commit 2026-10-04 11:38:16 +02:00
useradd --system warns when the uid it is given is above SYS_UID_MAX,
999 on Ubuntu; --key raises that limit for this one call, so the uid
stays 65532.

minsysusers, which runit's install runs to create its _runit-log user,
prints a Perl warning because runit's sysusers line leaves out the
shell. It reads /etc/sysusers.d/runit.conf in place of runit's file, so
the image writes a copy of that line there, naming the shell minsysusers
gives anyway; the user it creates is unchanged.

The runsvinit warning stays, since it needs a change to runsvinit: its
reaper and its own wait on runsvdir race for the same exited process.

Model: opus-5-5
clawbot added 1 commit 2026-10-04 11:50:10 +02:00
shortTimeoutSetting is now shortTimeout.String(), so changing waitLimit
changes both together.

Model: opus-5-5
clawbot added needs-checks and removed merge-ready labels 2026-10-06 00:49:21 +02:00
sneak was unassigned by clawbot 2026-10-06 00:49:21 +02:00
clawbot self-assigned this 2026-10-06 00:49:21 +02:00
clawbot added merge-ready and removed needs-checks labels 2026-10-06 02:33:18 +02:00
clawbot removed their assignment 2026-10-06 02:33:18 +02:00
sneak was assigned by clawbot 2026-10-06 02:33:18 +02:00
clawbot added 1 commit 2026-10-06 02:36:28 +02:00
Adds SWWAF_ALLOW_NETS, SWWAF_RATE_LIMIT_EXEMPT_NETS and SWWAF_DENY_NETS,
read like SWWAF_TRUSTED_PROXIES and empty by default, and checked against
the client's own address before its country is looked up. A client in
SWWAF_ALLOW_NETS skips the country lists and the rate limits and is not
looked up. One in SWWAF_DENY_NETS is refused with 403, logged as denied
and not counted. One in SWWAF_RATE_LIMIT_EXEMPT_NETS is neither counted
nor refused by the rate limits. SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES now
refuses a private, loopback or link-local client unless SWWAF_ALLOW_NETS
lists it.

Judgement call: an address in both SWWAF_ALLOW_NETS and SWWAF_DENY_NETS is let through.
Judgement call: the size and time limits still apply to SWWAF_ALLOW_NETS.

Model: opus-5-5
clawbot added 1 commit 2026-10-06 04:13:36 +02:00
The vendored files are fetched from sneak/prompts commit dd4027b. This
repository's own entries come after the canonical content, at the end of
each file: /bin in .dockerignore, the Go lines of .gitignore and [*.go]
in .editorconfig; the test-support deny list has no entries of its own.
The lint phase moves to golangci-lint v2.14.0. The build stage now takes
the version from git describe on the .git the build context carries,
unless VERSION is passed, and fails when .git is present but no version
comes out. The test phase drops -count=1, which the policy says it does
not need, and keeps its tmpfs build cache. One test calls Header.Get
with X-Real-IP, as canonicalheader asks.

Model: opus-5-5
clawbot added 1 commit 2026-10-06 05:05:34 +02:00
SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES (default 32K) and
SWWAF_CLIENT_IDLE_TIMEOUT (default 120s) replace the two values the
proxy fixed. The idle time is read like the other durations, and can
be off.

Go's server reads 4K past the header limit it is given before it
refuses, so it is still given the setting less 4K. The header size
must be more than 4K and cannot be off; any other value stops the
start with a message that does not offer off.

SPEC.md and README.md say so. README.md lists both settings, no longer
calls them fixed, and names them as built.

Model: opus-5-5
clawbot added 1 commit 2026-10-06 05:29:04 +02:00
A request over a rate limit is refused with SWWAF_BAN_RESPONSE and bans
the client's netblock: an hour at first, three times the last ban when
broken again within a day of its end, permanent past seven days. The
ban ledger in internal/bans is checked after the static lists and
before the lookup, and the requests it refuses are not counted. A ban
resets the client's counters and carries notes holding the request
that broke the limit, as SPEC.md now says. At most SWWAF_MAX_BANS are
held. SWWAF_BAN_RESPONSE also answers SWWAF_DENY_NETS and the country
lists.

Judgement call: the six ban settings cannot be off.
Judgement call: a permanent ban's ban_expires is "permanent".

Model: opus-5-5
clawbot added 1 commit 2026-10-06 08:31:54 +02:00
smallwebwaf now copies its state to bans.json, clients.json and
lookups.json in SWWAF_STATE_DIR, as "Persistent state" in SPEC.md
describes, and reads them back at start, so a restart lifts no ban and
gives no client a fresh allowance. Each client gains a history, and a
ban's notes count the netblock's requests. bans.json is written
SWWAF_STATE_WRITE_DELAY after a ban, and every file every
SWWAF_STATE_COUNTER_INTERVAL and at the stop. A ban read back is masked
to its netblock and refuses every client in it. A file that does not
parse, an unknown version, an entry without a field it needs, or an
unwritable directory stops the start.

Deviation: no AS number or name, and no ban cause, reason or lifting yet.

Model: opus-5-5
clawbot added 1 commit 2026-10-06 09:12:51 +02:00
The tests that have GeoJS asked now run in a synctest bubble, so a wait
lasts exactly as long as it should however slowly the test process runs:
a new client's wait is checked to be exactly one second, and its next
request exactly no wait. A request waiting on the network would stop the
bubble's clock, so the stand-in for GeoJS now answers in place of the
network, through a transport that a test-only file lets the tests set.
The test of the failure log uses an abandoned request instead of a
closed port.

Model: opus-5-5
clawbot added 1 commit 2026-10-06 11:40:29 +02:00
GET /_smallwebwaf/metrics answers in the Prometheus text format for a
request carrying SWWAF_METRICS_TOKEN, 401 without it and 404 while it is
unset. Every request under /_smallwebwaf/ but the health check now goes
through the checks and is answered where it would be forwarded, 404 for
any path but the metrics, so none reaches the app. In the client's
history a 401 counts as refused, the metrics and the 404s as neither.
SWWAF_METRICS_TOP_N bounds the series by country, the rest counted as
other.

Deviation: go.mod and go.sum written by hand, as go runs only through
make.
Deviation: no metrics yet for state files read again after an edit or
edits set aside; that work is not merged.

Model: opus-5-5
clawbot added 1 commit 2026-10-06 13:04:44 +02:00
SWWAF_MODE (default enforce) takes enforce or observe. In observe mode a
request that SWWAF_DENY_NETS, a ban, the country lists or a rate limit
would refuse is passed to the app, and its log line names that refusal
in would_action. The size and time limits and the 401 still apply. A
broken limit makes no ban; bans read from bans.json are kept but refuse
nothing, and Ledger.Find reads them without counting a refusal in their
notes.

Judgement call: in observe mode a broken limit does not reset the
client's counters, since the reset comes with the ban.
Judgement call: a request a ban would refuse keeps ban_expires.

Model: opus-5-5
clawbot added 1 commit 2026-10-06 14:18:15 +02:00
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
Some checks are pending
check / check (push) Waiting to run
You are not authorized to merge this pull request.
This pull request can be merged automatically.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin next:next
git checkout next
Sign in to join this conversation.