With SWWAF_LOG_REMOTE_URL set (syslog+udp, syslog+tcp or syslog+tls),
every line on stdout is also sent as the message of an RFC 5424 record,
octet-counted over TCP and TLS, from a bounded buffer that drops its
oldest line when full, so a slow or unreachable server holds up nothing.
Failed connections are retried with backoff; lines sent, dropped and
waiting are metrics. At a stop the lines still waiting get at most two
seconds. Standard library only: log/syslog writes only the older format.
Deviation: SWWAF_LOG_REMOTE_APP_NAME defaults to the host's name until
SWWAF_INSTANCE_NAME exists.
Model: opus-5-5
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
The smallwebwaf image is now built on the newest Ubuntu LTS release, 26.04 today, pinned by digest and moved to the next LTS when that ships, with Nix and nixpkgs installed, as sneak ruled. nixpkgs is pinned to one commit of its newest release branch with a hash the build checks, so an app's build gives the same packages each time. The example app Dockerfiles add a package from nixpkgs and create the app's user with useradd, and every run script is bash with set -euo pipefail, as the style guide asks. The new base settles two of the Alpine points of the deploy follow-up. Building the image stays with milestone 2.
Model: opus-5-5
SPEC.md now follows sneak's milestones. The build order starts with milestone 1 and milestone 2, each described briefly and linked, then the earlier stages less what the two milestones build; milestone 2 carries the container image, runit and the health check on /_smallwebwaf/healthz. The four body-size settings become SWWAF_REQUEST_MAX_BYTES and SWWAF_RESPONSE_MAX_BYTES, since smallwebwaf passes bodies through unchanged; the four timeouts stay. GeoJS answers are kept in memory, and writing them to lookups.json comes in milestone 3 or later, as he ruled. README.md loses the two sentences this made wrong.
Model: opus-5-5
SPEC.md and README.md now describe sneak's recommended deploy: an app's Dockerfile builds FROM the smallwebwaf image, and runit, started by runsvinit, runs smallwebwaf on :8080 in front of the app on 127.0.0.1:8081, so no setting is required. The spec covers what the app's Dockerfile adds, the users each process runs as, restarts, the health check, ports, the state directory and its volume, the app trusting loopback for forwarded headers, and upaas needing no change. An example app Dockerfile replaces the docker-compose examples. Every setting carries the SWWAF_ prefix, the file form too, and "sidecar" no longer names the deploy shape.
Model: opus-5-5
SPEC.md and README.md now state the resolutions of the old questions section and sneak's later requirements: seven-day bans for clear signs of attack and short, tripling bans for broken limits; state files that follow memory and take in edits while running; ban notes and per-client history; size and time limits in both directions; country deny and allow-only lists; GeoJS as the default lookup source; one listener for everything. The defaults are chosen so that a sidecar with only UPSTREAM_URL set protects an app on the open internet; the Core Rule Set reads no request bodies by default. Choices made where his words left a gap are listed in the PR. Gitea requests the defaults may still refuse are collected in a follow-up issue.
Model: opus-5-5