Directive (sneak, in chat, 25 September 2026), verbatim:
write a first milestone for smallwebwaf that implements the basic proxy functionality, allows an http request to pass through, and implements timeouts for read and write from downstream client and upstream server and enforces maximum request/response sizes, and logging. design it with an eye to have the second milestone being simple per-ip rate limits and country white/blacklisting. we're going to get it into prod by the second milestone, so the most basic mvp is going to be fit for purpose for rate limiting and country filtering.
and minutes later:
write the second milestone too. each one should be one issue.
This is the milestone that goes to production. It builds on milestone 1, #13, starts once that is on next, and lands as one PR to next.
What it adds
Per-IP rate limits:
A client is one IPv4 address, or one IPv6 /64, since one abuser usually holds a whole /64.
Each client's requests are counted over a minute, an hour and a day, as the "Counting method" section of SPEC.md describes: two buckets per window, the older one weighted by how much of it the window still covers.
A request that takes a client over a limit is refused with 429, and so is each request after it until the client is back under every limit. Refused requests count, so a client that keeps sending too fast stays refused until it slows down.
SWWAF_DENIED_COUNTRIES: a request from a listed country is refused.
SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES: a request from any other country is refused, and so is one from a client whose country cannot be found.
A refused request gets 403 as soon as the client's country is known, before its body is read. It skips the rate limits and is logged with the actioncountry_denied and its country.
Codes are two-letter ISO codes in either case. An unknown code stops the start with a message naming it: North Korea is kp, and the nk in the example on issue 8 is not a code. Both lists may be set; a code on both stops the start.
A client on a private, loopback or link-local address has no country, and neither list checks it.
only while a country list is set, so otherwise no visitor's address leaves the host; private, loopback and link-local addresses are never sent;
each answer kept in memory for 7 days (sneak's comment on issue 11), and used instead of asking GeoJS again; this in-memory cache is required (sneak, 25 September);
a new client waits at most one second for its answer, and without one counts as a client whose country cannot be found until the answer arrives;
at most one request to GeoJS at a time, carrying every address waiting, so a swarm of new addresses costs few requests;
while GeoJS fails, clients with a kept answer are unaffected, new ones count as not found, and GeoJS is asked again with backoff.
Nothing is written to disk: the rate counters and the kept answers live in memory only and start empty after a restart.
Bounded memory: at most 20,000 clients and 100,000 kept answers, the least recently seen dropped first.
The log line gains country and limit_hit (which window), and the action values rate_limited and country_denied.
Settings
SWWAF_-prefixed, each with a default; a value that is set but invalid stops the start.
SWWAF_RATE_LIMIT_PER_MINUTE1000, SWWAF_RATE_LIMIT_PER_HOUR10000, SWWAF_RATE_LIMIT_PER_DAY50000, the spec's defaults: several times what one busy person produces, since a browser loading a heavy page makes a few hundred requests and several people often share one address.
SWWAF_DENIED_COUNTRIES and SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES, both empty.
Ready for production
The container image of #12: an app's Dockerfile builds FROM it, runit with runsvinit starts both processes, and smallwebwaf listens on :8080 in front of the app on 127.0.0.1:8081. Issue 12's spec is on next before this milestone starts.
README.md documents the new settings and the deploy.
Definition of done
Tests show: each window refusing at its limit and letting the client back once it is under; refused requests counting; an IPv6 /64 counted as one client; both country lists, including a client whose country cannot be found and one on a private address; no lookup while neither list is set; a kept answer used without asking GeoJS again, and asked again after 7 days; every setting's default, and the start refused for invalid values. The tests use a local stand-in for GeoJS; none calls the real service.
An example app image built FROM the smallwebwaf image serves a request through smallwebwaf.
make check is green.
One PR to next, passed by a reviewer who did not write it.
Not in this milestone
Writing the kept answers to disk at an orderly stop and reading them at start (milestone 3 or later, sneak, 25 September), and the same for the rate counters. The bans of issue 2 and their notes (#4), lists of exempt networks, byte limits, lower limits for listed networks and countries, the IPinfo file as a second lookup source, metrics, rule files, the Core Rule Set, alerts and the admin listener: later milestones, each an issue of its own, 17 to 29 on this repo.
Choices made
Say on this issue to change any.
"Simple per-IP rate limits" is read as refusing while a client is over a limit, without bans. Issue 2's bans (an hour for a broken limit, longer on each repeat, permanent past seven days) come in a later milestone, with the ban file and its notes.
GeoJS is the only lookup source, as issue 11 proposed; the IPinfo file stays in the spec for later.
Private addresses skip the country lists: this milestone has no list of always-allowed networks, and without that rule the allow-only list would refuse monitoring and other containers.
The rate counters are not written to disk either, so this milestone has no state files and needs no volume: they would have been the only thing written, and a restart costs each client no more than a fresh allowance.
Country refusals are counted in the request log. The metrics that sneak's comment on issue 8 mentions come with the metrics endpoint in a later milestone.
Order
After milestone 1 and issue 12's spec are on next.
Model: opus-5-5
Directive (sneak, in chat, 25 September 2026), verbatim:
> write a first milestone for smallwebwaf that implements the basic proxy functionality, allows an http request to pass through, and implements timeouts for read and write from downstream client and upstream server and enforces maximum request/response sizes, and logging. design it with an eye to have the second milestone being simple per-ip rate limits and country white/blacklisting. we're going to get it into prod by the second milestone, so the most basic mvp is going to be fit for purpose for rate limiting and country filtering.
and minutes later:
> write the second milestone too. each one should be one issue.
This is the milestone that goes to production. It builds on milestone 1, https://git.eeqj.de/sneak/smallwebwaf/issues/13, starts once that is on `next`, and lands as one PR to `next`.
## What it adds
- Per-IP rate limits:
- A client is one IPv4 address, or one IPv6 /64, since one abuser usually holds a whole /64.
- Each client's requests are counted over a minute, an hour and a day, as the "Counting method" section of `SPEC.md` describes: two buckets per window, the older one weighted by how much of it the window still covers.
- A request that takes a client over a limit is refused with `429`, and so is each request after it until the client is back under every limit. Refused requests count, so a client that keeps sending too fast stays refused until it slows down.
- Country lists, from https://git.eeqj.de/sneak/smallwebwaf/issues/8, with sneak's names:
- `SWWAF_DENIED_COUNTRIES`: a request from a listed country is refused.
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES`: a request from any other country is refused, and so is one from a client whose country cannot be found.
- A refused request gets `403` as soon as the client's country is known, before its body is read. It skips the rate limits and is logged with the `action` `country_denied` and its country.
- Codes are two-letter ISO codes in either case. An unknown code stops the start with a message naming it: North Korea is `kp`, and the `nk` in the example on issue 8 is not a code. Both lists may be set; a code on both stops the start.
- A client on a private, loopback or link-local address has no country, and neither list checks it.
- Country lookup through GeoJS, as proposed in https://git.eeqj.de/sneak/smallwebwaf/issues/11:
- only while a country list is set, so otherwise no visitor's address leaves the host; private, loopback and link-local addresses are never sent;
- each answer kept in memory for 7 days (sneak's comment on issue 11), and used instead of asking GeoJS again; this in-memory cache is required (sneak, 25 September);
- a new client waits at most one second for its answer, and without one counts as a client whose country cannot be found until the answer arrives;
- at most one request to GeoJS at a time, carrying every address waiting, so a swarm of new addresses costs few requests;
- while GeoJS fails, clients with a kept answer are unaffected, new ones count as not found, and GeoJS is asked again with backoff.
- Nothing is written to disk: the rate counters and the kept answers live in memory only and start empty after a restart.
- Bounded memory: at most 20,000 clients and 100,000 kept answers, the least recently seen dropped first.
- The log line gains `country` and `limit_hit` (which window), and the `action` values `rate_limited` and `country_denied`.
## Settings
`SWWAF_`-prefixed, each with a default; a value that is set but invalid stops the start.
- `SWWAF_RATE_LIMIT_PER_MINUTE` `1000`, `SWWAF_RATE_LIMIT_PER_HOUR` `10000`, `SWWAF_RATE_LIMIT_PER_DAY` `50000`, the spec's defaults: several times what one busy person produces, since a browser loading a heavy page makes a few hundred requests and several people often share one address.
- `SWWAF_DENIED_COUNTRIES` and `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES`, both empty.
## Ready for production
- The container image of https://git.eeqj.de/sneak/smallwebwaf/issues/12: an app's Dockerfile builds `FROM` it, runit with `runsvinit` starts both processes, and smallwebwaf listens on `:8080` in front of the app on `127.0.0.1:8081`. Issue 12's spec is on `next` before this milestone starts.
- `README.md` documents the new settings and the deploy.
## Definition of done
- Tests show: each window refusing at its limit and letting the client back once it is under; refused requests counting; an IPv6 /64 counted as one client; both country lists, including a client whose country cannot be found and one on a private address; no lookup while neither list is set; a kept answer used without asking GeoJS again, and asked again after 7 days; every setting's default, and the start refused for invalid values. The tests use a local stand-in for GeoJS; none calls the real service.
- An example app image built `FROM` the smallwebwaf image serves a request through smallwebwaf.
- `make check` is green.
- One PR to `next`, passed by a reviewer who did not write it.
## Not in this milestone
Writing the kept answers to disk at an orderly stop and reading them at start (milestone 3 or later, sneak, 25 September), and the same for the rate counters. The bans of issue 2 and their notes (https://git.eeqj.de/sneak/smallwebwaf/issues/4), lists of exempt networks, byte limits, lower limits for listed networks and countries, the IPinfo file as a second lookup source, metrics, rule files, the Core Rule Set, alerts and the admin listener: later milestones, each an issue of its own, 17 to 29 on this repo.
## Choices made
Say on this issue to change any.
- "Simple per-IP rate limits" is read as refusing while a client is over a limit, without bans. Issue 2's bans (an hour for a broken limit, longer on each repeat, permanent past seven days) come in a later milestone, with the ban file and its notes.
- GeoJS is the only lookup source, as issue 11 proposed; the IPinfo file stays in the spec for later.
- Private addresses skip the country lists: this milestone has no list of always-allowed networks, and without that rule the allow-only list would refuse monitoring and other containers.
- The rate counters are not written to disk either, so this milestone has no state files and needs no volume: they would have been the only thing written, and a restart costs each client no more than a fresh allowance.
- Country refusals are counted in the request log. The metrics that sneak's comment on issue 8 mentions come with the metrics endpoint in a later milestone.
## Order
After milestone 1 and issue 12's spec are on `next`.
Model: opus-5-5
clawbot
self-assigned this 2026-09-25 10:34:38 +02:00
Ruling (sneak, in chat, 25 September): the GeoJS client must keep its answers in memory. Writing that cache to disk at shutdown and reading it back at start is milestone 3 or later.
The body above is edited to match. So that this milestone writes nothing to disk, the rate counters stay in memory only as well; that part is my choice, listed under "Choices made".
Model: opus-5-5
Ruling (sneak, in chat, 25 September): the GeoJS client must keep its answers in memory. Writing that cache to disk at shutdown and reading it back at start is milestone 3 or later.
The body above is edited to match. So that this milestone writes nothing to disk, the rate counters stay in memory only as well; that part is my choice, listed under "Choices made".
Model: opus-5-5
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Directive (sneak, in chat, 25 September 2026), verbatim:
and minutes later:
This is the milestone that goes to production. It builds on milestone 1, #13, starts once that is on
next, and lands as one PR tonext.What it adds
SPEC.mddescribes: two buckets per window, the older one weighted by how much of it the window still covers.429, and so is each request after it until the client is back under every limit. Refused requests count, so a client that keeps sending too fast stays refused until it slows down.SWWAF_DENIED_COUNTRIES: a request from a listed country is refused.SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES: a request from any other country is refused, and so is one from a client whose country cannot be found.403as soon as the client's country is known, before its body is read. It skips the rate limits and is logged with theactioncountry_deniedand its country.kp, and thenkin the example on issue 8 is not a code. Both lists may be set; a code on both stops the start.countryandlimit_hit(which window), and theactionvaluesrate_limitedandcountry_denied.Settings
SWWAF_-prefixed, each with a default; a value that is set but invalid stops the start.SWWAF_RATE_LIMIT_PER_MINUTE1000,SWWAF_RATE_LIMIT_PER_HOUR10000,SWWAF_RATE_LIMIT_PER_DAY50000, the spec's defaults: several times what one busy person produces, since a browser loading a heavy page makes a few hundred requests and several people often share one address.SWWAF_DENIED_COUNTRIESandSWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES, both empty.Ready for production
FROMit, runit withrunsvinitstarts both processes, and smallwebwaf listens on:8080in front of the app on127.0.0.1:8081. Issue 12's spec is onnextbefore this milestone starts.README.mddocuments the new settings and the deploy.Definition of done
FROMthe smallwebwaf image serves a request through smallwebwaf.make checkis green.next, passed by a reviewer who did not write it.Not in this milestone
Writing the kept answers to disk at an orderly stop and reading them at start (milestone 3 or later, sneak, 25 September), and the same for the rate counters. The bans of issue 2 and their notes (#4), lists of exempt networks, byte limits, lower limits for listed networks and countries, the IPinfo file as a second lookup source, metrics, rule files, the Core Rule Set, alerts and the admin listener: later milestones, each an issue of its own, 17 to 29 on this repo.
Choices made
Say on this issue to change any.
Order
After milestone 1 and issue 12's spec are on
next.Model: opus-5-5
Ruling (sneak, in chat, 25 September): the GeoJS client must keep its answers in memory. Writing that cache to disk at shutdown and reading it back at start is milestone 3 or later.
The body above is edited to match. So that this milestone writes nothing to disk, the rate counters stay in memory only as well; that part is my choice, listed under "Choices made".
Model: opus-5-5