Files
smallwebwaf/SPEC.md
T
clawbot 344ce13c96
check / check (push) Failing after 14m44s
rproxy and servewaf replace the one-process smallwebwaf (closes #172, closes #165)
`smallwebwaf` with no command now prints the commands and exits with status 2.
The image runs rproxy, or servewaf with SMALLWEBWAF_COMMAND=servewaf. The
passing-on code, its settings and its log fields and metric go. internal/proxy
keeps the checks, answers, reports and own endpoints; its tests run through the
real rproxy. The documents describe the pair.

Judgement call: clients.json keeps token_refused, since state files refuse unknown fields.
Deviation: script/gitea-requests sets SWWAF_LOG_REQUEST_HEADERS, to label requests.
Partially verified: make example-app and make gitea-requests never built the image, as Ubuntu's snapshot service answered 5xx.

Model: opus-5-5
2026-11-05 13:15:05 +01:00

1720 lines
91 KiB
Markdown

# smallwebwaf specification
`smallwebwaf` is a web application firewall for self-hosted apps. It is one
binary, and two of its commands do the work:
- `smallwebwaf rproxy` is a reverse proxy between traefik and one app.
- `smallwebwaf servewaf` decides what happens to each request, for many sites.
This document says what it must do and why, each requirement once. Everything in
it is built, except what the last section lists. `README.md` says how to run it.
`EVALUATION.md` says why no existing tool was adopted. The reference documents
under `docs/` give the settings, the formats and the lists. This document links
to them.
## Purpose
- It protects self-hosted apps on the open internet from scrapers, scanners and
other abusers. It does so from an app's first request. `rproxy` requires only
the address of `servewaf`. In a container of its own it also needs the app's
address. Every other setting has a default chosen for a service facing the
internet in 2026. A setting that needs an alert destination, an account key or
a token stays off until the operator supplies it.
- It keeps a site answering while the part that decides is down, slow or
restarting (see "Without an answer from `servewaf`").
- It limits each client's requests and bytes per minute, hour and day. It bounds
the size and duration of every request and answer. It detects attacks.
- It bans abusers: briefly at first, for seven days on a clear sign of attack,
and permanently when they keep at it.
- It looks up each client's AS number and country. It can refuse whole countries
and netblocks, lower its limits for listed AS numbers and countries, consult
reputation sources, and send alerts.
- Everything is set by environment variables. The names of `servewaf`'s start
with `SWWAF_`, and those of `rproxy` with `SWRP_`. The only other inputs are
`servewaf`'s files:
- the rule files;
- the state files;
- a lookup database;
- a `SWWAF_` setting given as a file;
- the file `SWWAF_LOG_REMOTE_TLS_CA_FILE` names.
## Non-goals
- TLS, certificates and routing by host name. Those are traefik's job.
- More than one app behind one `rproxy`. Each app has its own.
- Browser challenges such as captchas or proof of work. If wanted, chain Anubis
between `rproxy` and the app.
- Defence against floods that saturate the host's network link. That needs help
upstream of the host.
- A web console or a configuration file.
## Architecture
- One statically linked Go binary. Only `servewaf` writes to disk, and only to
its state directory. It ships in one image, which runs either command (see
"Deployment").
- No database of any kind. Nothing is read from disk while serving a request.
- Packages:
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
- `internal/smallwebwaf`: the process, as the command it is given.
`smallwebwaf servewaf` reads the settings, the rule files, the lookup
database and the state files, listens, and serves until `SIGTERM` or
`SIGINT`. Then it stops, writing the state files (see "`servewaf`").
`smallwebwaf rproxy` reads only that command's settings, and runs
`internal/rproxy` until `SIGTERM` or `SIGINT` (see "`rproxy`").
`smallwebwaf healthcheck` is the image's health check. With no command, or
one it does not know, the binary prints the commands and exits with
status 2.
- `internal/config`: reads the settings, the one place they are read.
- `internal/proxy`: `servewaf`'s server. It runs the checks in `check`, and
holds `servewaf`'s question, answer and report. It answers `servewaf`'s
own endpoints in `answerOwnEndpoint`. It counts bytes, refusals and
anomalies, and writes the log lines.
- `internal/rproxy`: the reverse proxy in front of one app that asks
`servewaf` about each request. It passes a request to the app with the
standard library's `httputil.ReverseProxy`, within the time and size
limits.
- `internal/ratelimit`: the table of clients. It counts requests, bytes and
refusals, and keeps each client's history.
- `internal/bans`: the bans. It holds each netblock's bans with their notes,
decides how long a new ban lasts, and picks the ban to drop when the table
of bans is full.
- `internal/rules`: reads the rule files at start and again when they
change, and tells which rules a request matches.
- `internal/waf`: the Core Rule Set, with the six changes listed under
"Attack detection", as Coraza directives.
- `internal/lookup`: looks clients up through GeoJS or in the lookup
database.
- `internal/reputation`: fetches the lists, asks the DNS blocklists and
AbuseIPDB in the background, and keeps their answers.
- `internal/anomaly`: the anomaly counters and their alerts.
- `internal/alerts`: holds back repeats and alerts past the hourly limit,
and sends the rest to the webhook, Slack and ntfy, each from a queue of
its own.
- `internal/state`: reads the state files at start, takes in edits while
running, and writes the files when due and at the stop.
- `internal/requestlog` and `internal/remotelog`: the lines on stdout, and
sending them to the syslog server.
- `internal/metrics`: the metrics, in the Prometheus text format.
- `Dockerfile`: the lint and test phases, then the image.
`deploy/example-app` is an app built on the image, with `rproxy` in its
container, which `script/example-app` checks with `servewaf` in another.
- Libraries, checked against the owner's Go dependency defaults:
- the standard library for the reverse proxy, HTTP clients, DNS, logging
(`log/slog`) and the state files (`encoding/json`, `os.Rename`);
- `github.com/corazawaf/coraza/v3` and
`github.com/corazawaf/coraza-coreruleset/v4` at `v4.25.0`, which carries
the Core Rule Set 4.25.0. The changes `smallwebwaf` makes to it, and the
default `SWWAF_WAF_DISABLED_RULES`, are built for that version. They are
checked again before the version changes, since rule ids, and what each
rule matches, change between versions;
- `github.com/hashicorp/golang-lru/v2` for the bounded tables;
- `github.com/oschwald/maxminddb-golang/v2` to read the lookup database;
- `github.com/fsnotify/fsnotify` to notice edited files;
- `github.com/prometheus/client_golang` for the metrics;
- `github.com/maxmind/mmdbwriter`, in the tests, to write lookup databases;
- remote log sending written in this project, since the standard library's
`log/syslog` is frozen and writes only the older syslog format.
## What happens to a request
`rproxy` runs steps 1, 2 and 14, and sends the report of step 15. `servewaf`
runs steps 3 to 13, and does the counting of step 15. The first step that
produces a final answer ends the checks. Step 15 runs for each request
`servewaf` answers with `allow`, `deny` or `close`. `rproxy`'s size and time
limits apply to the whole exchange. Each step names the section that holds its
rules.
1. `GET /_smallwebwaf/healthz` is answered at once by `rproxy`, before any
check, so a health checker never needs an exemption and is never refused.
2. The client is identified.
- If the TCP peer is inside `SWRP_TRUSTED_PROXIES`, `X-Forwarded-For` is
walked from the right, and the first address outside those netblocks is
the client. If every address is inside them, the leftmost is, so that a
visitor on a private network is known by its own address. An entry that is
not an address ends the walk. With no header, the peer is the client.
- If the peer is outside `SWRP_TRUSTED_PROXIES`, it is the client, and the
header is ignored.
- `rproxy` then sends `servewaf` the question (see "The question").
`servewaf` takes the address the question gives as the client.
- An IPv6 client is its group: the netblock of `SWWAF_IPV6_GROUP_PREFIX`
around its address, a /64 by default, since one abuser usually controls a
whole /64. A client is therefore one IPv4 address or one IPv6 group.
3. The static lists. A client in `SWWAF_ALLOW_NETS` skips steps 4 to 12, the
byte limits and the error burst. A client in `SWWAF_DENY_NETS` is refused; a
client in both lists is let through.
4. Bans. A client under a ban is refused (see "Bans").
5. The lookup, unless `SWWAF_LOOKUP_SOURCE` is `off` (see "Lookup of AS number
and country"). The request goes on at once. It waits for the first GeoJS
answer, up to `SWWAF_LOOKUP_TIMEOUT`, only when one of these needs it:
- a country list;
- a biased threshold;
- `SWWAF_ADD_LOOKUP_HEADERS`.
A request that ends before the answer comes is logged without it.
6. The country lists (see "Country lists").
7. The reputation sources (see "Reputation"). A client on a fetched blocklist
gets `SWWAF_BLOCKLIST_ACTION`. A client on the CrowdSec decision list is
banned. A client listed by a DNS blocklist, or scored at least
`SWWAF_ABUSEIPDB_MIN_SCORE` by AbuseIPDB, gets `SWWAF_REPUTATION_ACTION`.
8. The client's limit percentage (see "Biased thresholds").
9. Rate limiting (see "Request rate limits, per client"). A request over a limit
times the percentage is refused and bans the client.
10. The trap paths (see "Attack detection"). A request for one is refused and
bans the client.
11. The rule files (see "Rule files"). Matching stops at the first rule that
refuses.
12. The header limits (see "Answering in time"), and then the Core Rule Set (see
"Attack detection"). A Core Rule Set match is refused with `403` while
`SWWAF_WAF_MODE` is `block`, and only logged and alerted while it is
`detect`.
13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees
that prefix.
14. `rproxy` passes the request to `SWRP_UPSTREAM_URL`, streaming (see "Passing
a request to the app"). With `SWWAF_ADD_LOOKUP_HEADERS`, `X-Client-ASN` and
`X-Client-Country` are added for the app's own logs; a header whose value is
unknown is left out. Whatever that setting says, any such header the client
sent is removed.
15. After the visitor's answer has ended, `rproxy` sends the report (see "The
report"). `servewaf` then counts:
- For a request it allowed, the response bytes, the request bytes or both
are added to the byte counters, as `SWWAF_BYTES_COUNT` says. For a
WebSocket, what it carried each way counts once it closes. A client whose
total passes a byte limit times the percentage has broken that limit and
is banned. The response in progress is not cut off.
- For a request it allowed, the client's history and the anomaly counters
are updated, and the alert thresholds are evaluated.
For a request it refused, `servewaf` adds nothing to the byte limits. It
updates the history and the anomaly counters at its decision. It counts the
refusals the error burst counts at the decision too (see "Attack
detection").
In `SWWAF_MODE=observe`, every refusal above is logged as what would have
happened, nothing is refused, and `servewaf` makes no ban of its own. A path
under `/_smallwebwaf/` is still answered `404`, and `rproxy`'s size and time
limits still apply.
## Counting method
- Each window (minute, hour, day) uses two adjacent fixed buckets per client.
The earlier bucket is weighted by how much of it still overlaps the sliding
window. This costs a few integers per client per window and avoids the burst
at bucket boundaries that a single fixed bucket allows.
- Counters are read and updated in memory. They are written to `clients.json`
and loaded again at start (see "Persistent state"), so a restart does not hand
every client a fresh allowance. Buckets whose time has passed are discarded on
load.
- Memory is bounded. When the table of clients holds
`SWWAF_MAX_TRACKED_CLIENTS`, the least recently seen client is dropped first,
with its history. The bans are bounded by `SWWAF_MAX_BANS` (see "Bans"). The
GeoJS answers, the reputation verdicts and scores, the DNS queries under way
and the anomaly counters have fixed bounds, listed under "Fixed limits" in
`docs/settings.md`. Past its bound, the GeoJS answer or anomaly counter used
longest ago goes first, and the verdict or score fetched longest ago. Past the
bound on DNS queries under way, a zone is asked at the client's next request.
## Bans
Until a ban ends, every request from its netblock is refused with
`SWWAF_BAN_RESPONSE`. The netblock is the client: its IPv4 address, widened to
`SWWAF_BAN_SCOPE_V4_PREFIX`, or its IPv6 group. At the defaults a ban covers one
address or one /64, so a permanent ban does not reach neighbours who did
nothing. Requests refused under a ban are not counted against limits. They are
counted in the client's history and in the ban's notes.
An offence is a request `servewaf` holds against the client. It is one of:
- a clear sign of attack;
- a broken limit;
- a refusal by a rule file or by the Core Rule Set.
Offences are counted by kind in the client's history. The first two kinds lead
to a ban, each by its own rule:
- A clear sign of attack: a request that matches a rule whose action is `ban`,
or asks for a path in `SWWAF_TRAP_PATHS`. Examples are a probe for a `.env`
file or a `.git` directory, and a known scanner's user agent.
- The first one bans the netblock for `SWWAF_ATTACK_BAN_DURATION` (default
`7d`), with the cause `attack`.
- Any further request from the netblock while that ban lasts shows it is
malicious: the ban becomes permanent. No other cause of ban becomes
permanent this way.
- Once that ban has run out, the netblock is served like any other. Its next
clear sign of attack bans it permanently at once.
- A broken limit, one of:
- a request over a rate limit;
- a response that takes the client's byte total past a byte limit;
- more than `SWWAF_ERROR_BURST_THRESHOLD` refusals within a minute.
The bans for a broken limit:
- The first such ban lasts `SWWAF_LIMIT_BAN_DURATION` (default `1h`), with
the cause `limit`. So does one that comes more than
`SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`) after the last such ban
ended.
- Breaking a limit again within that window makes the new ban three times as
long as the last one: one hour, then 3, 9, 27 and 81 hours.
- A ban that would be longer than `SWWAF_MAX_BAN_DURATION` (default `7d`) is
permanent instead. At the defaults, that is the sixth ban in a row.
- Each such ban sets the client's counters for every limit back to zero.
Once it ends, only new requests can break a limit again. The client's
history keeps its totals.
A Core Rule Set match refuses only the request it matched, with `403` and
without a ban, because the Core Rule Set has false positives. A `block` rule
does the same. Those refusals count toward the error burst, so a client that
keeps setting them off is banned for a broken limit.
A client on the CrowdSec decision list is banned until the decision ends, with
the cause `crowdsec`. Only a client that sends a request is banned, so a long
list does not fill `bans.json`. Such a ban is never made permanent. It does not
make the netblock's next ban for a broken limit longer. A ban lifted while the
decision is in force is made again at the client's next request. To lift it for
good, delete the decision in CrowdSec, then lift the ban once the list has been
fetched again.
These refuse each request they cover, record nothing in `bans.json`, and count
no offence:
- `SWWAF_DENY_NETS`;
- a blocklist or reputation hit whose action is `deny`;
- the country lists.
Every ban carries notes with what an admin needs to decide whether to lift it,
drawn from what `servewaf` already knows. No lookup is made for the notes alone.
`docs/state-files.md` lists the notes with the rest of a ban's entry.
An admin adds, keeps or lifts a ban by editing `bans.json` (see "Persistent
state") or through the ban endpoints (see "Admin endpoints"). A ban an admin
makes has the cause `admin`; an admin who wants to keep a ban `servewaf` made
sets its cause to `admin`. A ban lifted before it ends is kept, marked as
lifted, and does not count toward a longer ban; it refuses nothing. Deleting its
entry from `bans.json` forgets it entirely.
A permanent ban does not run out. It ends when an admin lifts it, or when
`servewaf` drops it from a full table, as below. When `servewaf` holds
`SWWAF_MAX_BANS` bans of its own, it drops the earliest ban of one netblock: the
netblock that has gone longest without a request. A past, active or permanent
ban is dropped alike. A scanner whose ban was dropped that way is refused and
banned again by its next probe. A ban whose cause is `admin` is never dropped
and does not count toward `SWWAF_MAX_BANS`, so such bans can never fill the
table.
## Settings
`docs/settings.md` lists every setting with its default and the values it takes,
in the groups below. It also gives the form each kind of value takes. The groups
below are `servewaf`'s settings; "`rproxy`" gives the use of `rproxy`'s.
Conventions:
- These settings take `off`, which switches them off:
- `SWWAF_LOOKUP_SOURCE` and `SWWAF_WAF_MODE`;
- the three rate limits and the three byte limits;
- `SWWAF_WAF_ANOMALY_THRESHOLD`, `SWWAF_WAF_BODY_LIMIT` and
`SWWAF_ERROR_BURST_THRESHOLD`;
- `SWWAF_ALERT_COOLDOWN` and `SWWAF_ALERT_MAX_PER_HOUR`;
- every anomaly threshold;
- `SWRP_MAX_REQUEST_SIZE`, `SWRP_MAX_RESPONSE_SIZE`,
`SWRP_MAX_REQUEST_TIME_MS` and `SWRP_MAX_RESPONSE_TIME_MS`.
No other setting can be switched off with `off`.
- A list set to an empty value is an empty list, and replaces the default.
- Any `SWWAF_` setting can be read from a file instead, for secrets and long
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
The file's contents are the value, less one newline at the end, and are
checked as the value itself would be. Setting both forms stops the start, and
so does naming a file that cannot be read. `SWWAF_LOG_REMOTE_TLS_CA_FILE`
already holds a file name, so it has no `_FILE` form. No `SWRP_` setting has
one, since `rproxy` never touches disk.
- Settings, files included, are read once at start. Changing one means
restarting the container. The files `servewaf` watches while it runs are its
state files, its rule files and the lookup database.
- An invalid setting stops the process at start with one log line, marked
`"type":"process"` with `"msg":"invalid setting"`. The line names the variable
and what is wrong with the value; "Each container's processes" says what
happens then. At start the settings in effect are logged on one line; tokens,
keys and the paths of webhooks, which often carry a secret, are shown as
`********`.
### Core
- `SWWAF_INSTANCE_NAME` is in each line `servewaf` logs, and in each metric and
alert, so that instances can be told apart. The default, the container's host
name, changes at every redeploy; a name set by the operator survives it.
- `SWWAF_MODE=observe` lets a configuration be tried before it is enforced (see
"What happens to a request").
- `SWWAF_IPV6_GROUP_PREFIX` sets the group size. After a change, each IPv6
client starts afresh; existing bans keep refusing their netblocks until they
end. `SWWAF_MAX_TRACKED_CLIENTS` and `SWWAF_MAX_BANS` set the bounds under
"Counting method" and "Bans".
### Tokens
- `SWWAF_ADMIN_TOKEN` switches on the ban endpoints and
`/_smallwebwaf/clients/<ip>`. The metrics are off until `SWWAF_METRICS_TOKEN`
is set, since without a token they would be open to anything on `servewaf`'s
network. The two are separate, so the metrics collector, which holds the
metrics token, cannot manage bans.
- A token is a long random value. One shorter than 32 characters stops the
start. The file form keeps a token out of the container's environment (see
"Tokens" under "Deployment").
### State files
- `SWWAF_STATE_DIR` names the directory of the state files (see "State" under
"Deployment").
- `bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made, lifted
or made permanent, so a burst of changes becomes one write. Every file is
written every `SWWAF_STATE_COUNTER_INTERVAL`, and at an orderly stop.
### Logging
- The request log on stdout is always on and has no switch (see "Request log").
`SWWAF_LOG_LEVEL` filters only the process's own messages, and no message is
at `debug`. `warn` and `error` hold back the line of settings at start.
- `SWWAF_LOG_REQUEST_HEADERS` names the request headers to record.
`Authorization`, `Cookie` and `Set-Cookie` values are never logged, even if
listed. The log says only whether `Authorization` and `Cookie` were present.
- `SWWAF_LOG_REMOTE_URL` names a syslog server (see "Request log").
### Metrics
- `SWWAF_METRICS_TOP_N` is how many of the busiest AS numbers and countries get
a series of their own. The rest are summed as `other`, so the label set stays
bounded.
### Static lists
- `SWWAF_ALLOW_NETS` skips the checks step 3 names, for monitoring or the
owner's own networks. `SWWAF_RATE_LIMIT_EXEMPT_NETS` bypasses the request and
byte limits only; the error burst, attack detection and bans still apply.
- Many people can share one address, as in an office, on a mobile carrier or
through Tor. They are one client, and share its limits and its bans. A known
shared address busier than the limits allow belongs in `SWWAF_ALLOW_NETS` or
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
### Request rate limits, per client
Breaking one bans the client. The defaults suit programmatic use of an API, such
as an automation fleet on another host, and still stop abusive bots. A fleet of
20 agents, each making a request every 10 seconds at its busiest, makes 120
requests a minute and 7,200 an hour. Working around the clock, it makes 172,800
a day. It stays under all three limits.
- Per minute: 500, about 8 requests a second. It leaves room for the fleet's 120
a minute and a few quick first visits with an empty cache. A gitea page opened
with an empty cache loads about 40 files. A client firing requests as fast as
it can passes the limit within seconds.
- Per hour: 20,000, about 5.5 requests a second for a whole hour. A client that
keeps up 6 a second makes 21,600 in an hour, and passes it, at 360 a minute,
under the minute limit. The fleet's 7,200 an hour is about a third of it.
- Per day: 300,000, about 3.5 requests a second around the clock. A scraper that
keeps up 4 a second makes 345,600 a day, and passes it. At 240 a minute and
14,400 an hour, it stays under the other two limits.
Counting cannot catch a client that stays under all three limits, such as one
that keeps up 3 requests a second. Only the other checks can catch an abuser
that slow. They include the trap paths, the rule files, the Core Rule Set and
the blocklists. An abuser that trips none of them is not caught. A machine that
keeps up more than 500 requests a minute passes the minute limit. One that keeps
up more than about 5.5 a second for an hour passes the hour limit. One that
keeps up more than about 3.5 a second all day passes the day limit, as two busy
Actions runners on one address do. Such a machine's address belongs in
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as
static assets or health checks; their bytes are not counted either. A prefix is
matched exactly as sent, without the query, and is not percent-decoded:
`/assets/` matches `/assets/app.js`, and does not match `/Assets/app.js`,
`/%61ssets/app.js` or `/static/assets/app.js`. There are no wildcards. A path
holding `..`, a backslash or an encoded slash (`%2F`) is never exempt, since the
app may read it as a path outside the prefix. `SWWAF_WAF_EXEMPT_PATHS` is
matched the same way.
### Byte limits, per client
The hour and day limits sit about twice above what 40 people busy with heavy web
apps move together on one address. Each moves about 256 MiB an hour, for ten
hours a day. A response's bytes are counted when it ends (see step 15). So the
minute limit holds three of the largest responses, with their requests, ending
in the same minute. No single download then breaks a limit. An operator who
raises `SWRP_MAX_RESPONSE_SIZE` must raise the byte limits too.
### Lookup of AS number and country
On by default through GeoJS, which needs no account or file, so that country
lookup works with no setup. A local database file is the alternative for a
service that keeps its visitors' addresses on its own host.
- Only one source is used. The start stops, naming both settings, when `file` is
set without `SWWAF_LOOKUP_DB_PATH`, or `SWWAF_LOOKUP_DB_PATH` with any other
source. The start also stops when the source is `off` and a setting that needs
a lookup is set (`docs/settings.md` lists them).
- GeoJS is told the address of every new visitor, whether or not a setting uses
the answer, since the request log, the metrics and the ban notes carry it.
These addresses are never sent:
- one in `SWWAF_ALLOW_NETS` or `SWWAF_DENY_NETS`;
- one under a ban;
- a private, loopback or link-local address, which no source can place.
- For an IPv6 visitor, GeoJS is asked about the first address of its group.
Addresses are asked about together, up to 200 in one request
(`https://get.geojs.io/v1/ip/geo.json?ip=a,b,c`), one request at a time, which
keeps the number of requests to GeoJS low. Each answer's `country_code`, `asn`
and `organization_name` are read. An answer without a `country_code` leaves
the country unknown, and the `asn` `64512` leaves the AS number unknown.
- Each answer is kept for seven days, in memory and in `lookups.json`. Answers
are kept apart from the table of clients, so that an answer outlasts a client
dropped from that table. The answer reaches the client's history and ban notes
when it comes.
- `SWWAF_LOOKUP_TIMEOUT` is how long a visitor's request waits for its client's
first answer, when a setting needs it (see step 5). It also bounds each
request to GeoJS. A client whose answer has not come in time counts as unknown
until it comes, and its later requests do not wait. Its address is asked about
again in the background.
- A client the lookup cannot place has an unknown country and AS number, which
the country lists and the biased thresholds treat as described there. Nothing
else treats it differently.
- GeoJS publishes no rate limit, but its terms forbid "an excessive amount of
API requests", judged by GeoJS alone, and let it block a caller. While GeoJS
is slow, down or refusing `servewaf`, new clients count as unknown. GeoJS is
then left alone for a second, and twice as long after each further failure, up
to five minutes. Each failure raises a `source_failure` alert (see
"Alerting"). A service that cannot accept this uses the file.
- The file is the free IPinfo Lite database (`ipinfo_lite.mmdb`), whose `asn`,
`as_name` and `country_code` fields are read. The operator downloads it with a
free IPinfo account and mounts it read-only; `servewaf` never fetches it and
holds no account or token for it. IPinfo updates it daily. Its licence is
Creative Commons Attribution-ShareAlike 4.0, and IPinfo asks for a link to it
on the site that uses it, such as "IP address data is powered by IPinfo".
- The file is read whole at start; one that is missing or unreadable stops the
start. It is read again once it has gone 2 seconds without a change after
being replaced. A replacement that cannot be read is logged, sent as one
`file_error` alert and ignored. The database already loaded stays in use. A
client whose address the file does not hold counts as unknown.
### Country lists
Both are empty by default: the defaults judge a client by what it does, not by
where it comes from.
- `SWWAF_DENIED_COUNTRIES` refuses every request from a listed country.
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` lets through only clients placed in a
listed country. Every request from any other country is refused. So is every
request from a client the lookup cannot place, such as internal monitoring or
a visitor on a private address; those that should reach the app go in
`SWWAF_ALLOW_NETS`. A list that let unplaced clients through would let every
new client in whenever GeoJS stops answering. The cost: while GeoJS fails,
this list refuses every new client. No other failure of GeoJS, a reputation
source or an alert destination comes this close to taking the service down.
- Both may be set. `SWWAF_DENIED_COUNTRIES` then adds nothing, since the
exclusive list already refuses every other country. A code on both lists stops
the start with a message naming it.
- A refused request is answered with `SWWAF_BAN_RESPONSE`, as soon as the
client's address has been looked up. It is logged with the action
`country_denied`, and counted in the metrics.
### Biased thresholds
The biased thresholds lower the limits of clients in listed AS numbers and
countries. The lists are empty by default, for the same reason as the country
lists. The request log and the metrics show which AS numbers and countries a
service's abuse comes from.
- Each list gives AS numbers or countries a percentage, such as `AS14061:50`.
Their clients get that percentage of every request and byte limit
(`SWWAF_ASN_LIMIT_PERCENT`, `SWWAF_COUNTRY_LIMIT_PERCENT`). A lowered limit is
rounded down. `0` is a zero allowance: the client's first request breaks a
limit and bans it.
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` set the byte
limits' percentage alone, when it should differ from the request limits'. For
an AS number they list, they replace its percentage from
`SWWAF_ASN_LIMIT_PERCENT` and `SWWAF_ASN_LIMIT_PERCENT_URL`; for a country,
the one from `SWWAF_COUNTRY_LIMIT_PERCENT`.
- `SWWAF_ASN_LIMIT_PERCENT_URL` names a text file of `AS:percent` lines, so one
abuse-source list can be shared by every `servewaf` in the fleet. It is
fetched and kept like a blocklist (see "Reputation"). Where it and
`SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies.
- A client's limit percentage is the lowest of those that apply to it: its AS
number's, its country's, or a reputation hit's `limit:<percent>`. A client
without a country gets `SWWAF_UNKNOWN_LIMIT_PERCENT` in place of a country's.
The percentage is 100 if none applies. Clients given a lower percentage are
banned after fewer requests, first for a while and then, for repeated abuse,
permanently. The log line of each counted request, and the notes of each ban,
say which setting lowered the limit.
- No budget is shared by a whole country or AS number. One abuser could use it
up and lock out everyone else there: a denial of service nobody chose, and the
one this tool exists to prevent. Refusing a whole country is left to the
operator's choice, through the country lists.
### Attack detection
By default the Core Rule Set reads the method, the URL with its query string,
and the headers of every request. That is where automated attacks show:
injection in query strings, path traversal, attacks carried in headers,
scanners' user agents. It reads no request body. In front of a code forge such
as gitea, bodies carry what people write and publish, such as issue text and
files saved in the web editor. The Core Rule Set takes the shell commands and
code in them for attacks. The default gives up refusing an attack carried in a
body. The rule files, the limits and the bans still apply to the client that
sends it.
- The Core Rule Set skips a path under `SWWAF_WAF_EXEMPT_PATHS`.
- `SWWAF_WAF_PARANOIA_LEVEL` and `SWWAF_WAF_ANOMALY_THRESHOLD` are the Core Rule
Set's own two tuning values, at its own defaults. Each level up runs more
rules, which find more attacks and take more ordinary requests for attacks.
Each rule that matches adds its score to the request's, and a rule rated
critical adds 5. A request whose score reaches the threshold is a match, so by
default one critical rule is enough. With the threshold `off`, nothing is a
match, and scores are still logged. A request with more than 1000 query
parameters or body fields adds 5 to its score, since Coraza reads only the
first 1000. For a match, in `block` and `detect` mode alike, the log line
gives the rule ids and the score, and a `waf_block` alert is sent.
- `SWWAF_WAF_DISABLED_RULES` switches off rule ids when an app trips a false
positive. The default switches off six rules:
- 920340, 920420 and 920640 refuse a request for the type of its body, when
the type is missing or not on the Core Rule Set's short list;
- 920440 refuses a request for its file extension, such as `.sh` or `.sql`;
- 930130 and 930140 refuse a file or directory name in the path, such as
`.git/`, `.gitignore`, `Dockerfile`, `package.json` or an editor's
settings directory.
In front of a code forge these refuse git over HTTP, container image and
package uploads, and views of ordinary files in a repository. At the site
root, where no app serves such files, the default rule file bans the common
probes these rules caught (see "Rule files"). A list you give replaces the
default, so name these six ids in it to keep them off.
- `SWWAF_WAF_BODY_LIMIT` switches body inspection on, up to that size. It takes
at most `64K`, since `servewaf` gets no more of a body than that (see "The
answer"). Form data and multipart bodies are read up to the size. JSON and XML
bodies are read only when no larger than the size, since those cannot be read
in part. Any other body is not read: the Core Rule Set would read it as form
data, where binary content such as a git push trips rules written for text. A
body the Core Rule Set cannot parse adds 5 to the score, and so does a
multipart body cut by the limit in the middle of a part's header. Body
inspection suits apps whose forms carry no code; `docs/gitea.md` records what
it refuses in front of gitea.
- `SWWAF_TRAP_PATHS` names paths the app never serves and only scanners ask for,
such as `/wp-login.php` in front of gitea. This is the short form of a `path`
rule with the `ban` action, for deployments that mount no rule files. A trap
path is matched whole, without the query, against the path as sent and once
percent-decoded: `/wp-login.php` matches `/wp-login.php?x=1` and
`/%77p-login.php`, and does not match `/wp-login.php/` or
`/blog/wp-login.php`.
- `SWWAF_ERROR_BURST_THRESHOLD` counts a client's refusals per minute of these
kinds, and of no other:
- a rule file match;
- a trap path;
- a Core Rule Set match.
More than this breaks a limit (see "Bans"). A client trying one attack after
another is refused many times a minute; a person rarely more than a few
times. Answers the app gives are not counted, since they do not tell a
scanner from an ordinary client. In front of gitea, registry and git clients
get `404` and `401` by design, hundreds of times a minute.
`smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
gitea it would otherwise refuse ordinary requests. The changes hold in front of
every app, and no setting undoes them:
1. `PUT`, `PATCH` and `DELETE` are allowed methods besides `GET`, `HEAD`, `POST`
and `OPTIONS`. APIs, container image pushes and package uploads use them.
Other methods stay refused.
2. The request header `Expect` is allowed, since curl and git send it before
some large uploads. `Content-Encoding` is allowed on a request whose body the
Core Rule Set does not read, which by default is every request. git
compresses a fetch request over 1 KiB and says so in that header. On a body
the Core Rule Set does read, the header stays refused, since a compressed
body cannot be inspected. The other headers the Core Rule Set refuses, such
as `Proxy`, stay refused.
3. The `redirect_uri` parameter is not checked for a URL naming an IP address or
`localhost` (931100, 934110). Git Credential Manager, git-credential-oauth
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
`http://127.0.0.1` on the user's own machine. The server never fetches that
address.
4. Ten query parameters are not checked against the Core Rule Set's lists of
system files (930120), shell paths (932160) and command names (932260):
- `path`, `files`, `skip-to` and `sub_path`;
- `ref`, `sha`, `branch` and `workflow`;
- `artifactName` and `redirect_to`.
gitea puts into them file paths, branch and workflow names, and the page to
return to after signing in. It reads these values as names in a repository
or in its own records, or as a page of its own site, never as a command. Any
name on those lists can be an ordinary file or branch, such as `.gitignore`
or `docker-build`. In these parameters, what only these three rules would
catch reaches the app, such as the command `|cat /etc/passwd`. Path
traversal, SQL and script injection, and PHP, Java and Node.js code are
still refused there. Every other parameter keeps all three rules. An app
that uses one of these parameters as a file on the server, or passes it to a
shell, must check the value itself. A rule file can refuse it instead.
5. Two cookies are left out, and `Referer` keeps fewer rules:
- The Core Rule Set reads the request without the `gitea_flash` and
`redirect_to` cookies. gitea puts a message naming what was just done into
`gitea_flash`. It puts into `redirect_to` the page to return to after
signing in. The Core Rule Set takes file names in them, such as
`package.json`, for attacks. A browser sends such a cookie with every
request until gitea replaces it. gitea cannot replace it while those
requests are refused, so one refusal would keep the browser out of the
whole site. gitea shows the message with any script removed and returns
only to its own pages. Every other cookie is read in full. An app that
reads a cookie of either name must check it itself.
- `Referer` is not checked for a Unix command given without arguments
(932340) or for Java starting a process (944110). `Referer` names the page
a request comes from, on everything that page loads and on every link
followed from it. The Core Rule Set takes some page addresses for attacks,
such as a search for `env`, or OpenJDK's `Runtime.java`, whose path holds
both `runtime` and `java.`. All of those requests would be refused.
`Referer` keeps the other rules. An app that passes `Referer` to a shell
must check it itself.
6. Responses are not inspected. A raw file from a repository, such as a shell
script, looks to the response rules like source code leaking from the server.
The parameter and cookie names in these changes are matched without regard to
case, as Coraza matches them.
### Ban lengths, response and scope
`SWWAF_BAN_RESPONSE` is `403` by default, which makes a ban easy to recognise
when debugging; `429` and `close` are the alternatives. Behind traefik, `close`
does not leave the client unanswered: traefik answers `502`, as it does whenever
its backend drops a connection. `SWWAF_BAN_SCOPE_V4_PREFIX` widens an IPv4 ban
to the surrounding netblock. The four ban lengths are under "Bans".
### Reputation
No source is on by default. A source acts on a list kept elsewhere; the defaults
judge a client only by what it does here. AbuseIPDB and CrowdSec also need an
account or an engine of the operator's own. The list best suited to be on by
default, Spamhaus DROP, has terms, below, that forbid frequent fetching, and
Spamhaus may block an address that breaks them. Each `servewaf` fetches its own
copy, so several of them on one host, sharing one address, would often fetch too
close together.
- Blocklists (`SWWAF_BLOCKLIST_URLS`) are text files of addresses and netblocks,
one to a line, as the Spamhaus DROP list is written. Anything after a `;` or
`#` on a line is ignored. An IPv4-mapped address such as
`::ffff:192.0.2.0/120` is read as the IPv4 netblock it stands for. Spamhaus's
terms for DROP, on its DROP page:
- automated downloads must be at least one hour apart, and once a day is
more than enough in most cases;
- a product that uses it must credit The Spamhaus Project and keep the
list's date and copyright lines with the data.
So `SWWAF_BLOCKLIST_REFRESH` is never less than `1h`, and the last good copy
is kept whole, comment lines included, on failure and across restarts.
- Each list is fetched again `SWWAF_BLOCKLIST_REFRESH` after its last try, one
list at a time. `reputation.json` keeps when each was last tried, so a restart
does not fetch a list again before its time. A fetch fails when:
- the server answers other than `200`;
- the fetch does not finish within a minute;
- the list is longer than 16 MiB;
- a line is not an address or netblock, or, for the AS number file, not
`AS<number>:<percent>`.
The log, the alerts, the ban notes and the metrics name each list by its
URL, so a list's URL must not hold a secret.
- `SWWAF_BLOCKLIST_ACTION` is `deny` by default, which refuses every request
from a listed address and suits lists of networks that send nothing
legitimate, such as DROP. `limit:<percent>` gives listed clients that
percentage of every request and byte limit, and suits a list of addresses
shared with ordinary visitors, such as Tor exits. `log` only notes the hit in
the log line and raises an alert.
- A CrowdSec engine on the host (`SWWAF_CROWDSEC_LAPI_URL`) has its decision
list fetched every minute, with `SWWAF_CROWDSEC_LAPI_KEY` in the `X-Api-Key`
header. The list is kept like a fetched blocklist, and a client on it is
banned (see "Bans"). Only decisions of the type `ban` on an address or a
netblock are used; any other, such as a captcha or a country, is left out. A
decision lasts for the `duration` it had left when the engine answered. This
is how a fleet-wide blocklist can arrive without `servewaf` depending on
CrowdSec. A fetch fails when:
- the engine answers other than `200`, a redirect included;
- the fetch does not finish within a minute;
- the answer is longer than 16 MiB;
- the answer is not a list of decisions.
- Each DNS blocklist (`SWWAF_DNSBL_ZONES`) is asked about a client's own address
in the background, the first time the client is seen. No request waits for the
answer. The name asked about is the one RFC 5782 gives: the numbers of an IPv4
address reversed, or the 32 hex digits of an IPv6 address reversed. So
`192.0.2.99` is asked about as `99.2.0.192.dnsbl.dronebl.org`. An answer in
`127.0.0.0/8` lists the client, but one in `127.255.255.0/24` is Spamhaus
refusing the query. A name that does not exist does not list the client. These
are failures, after which the zone is not asked again for a minute:
- an answer in `127.255.255.0/24`;
- an answer outside `127.0.0.0/8`;
- a resolver error;
- no answer within `SWWAF_REPUTATION_TIMEOUT`.
DNS blocklists made for mail servers list residential ranges, and would
block ordinary visitors, so they should not be used. Spamhaus zones need
their keyed query service, given as a zone name that contains the key.
`servewaf` shows the key as `********` wherever it names the zone. Only
`reputation.json` keeps the key. `SWWAF_DNSBL_RESOLVER` names a resolver,
since public resolvers are refused by several list operators.
- AbuseIPDB (`SWWAF_ABUSEIPDB_KEY`) is asked, at its check endpoint, for the
abuse confidence score of a client, from 0 to 100. Only a client that has
already committed one offence is checked, so the daily budget
(`SWWAF_ABUSEIPDB_DAILY_BUDGET`, under the free tier's 1000 checks a day) is
spent on suspects. The check runs in the background at the client's next
request that no ban refuses, so no request waits; the score applies to the
client's later requests. For an IPv6 client, AbuseIPDB is asked about the
address of the request that set off the check, and the score is used for the
whole group. A score at or over `SWWAF_ABUSEIPDB_MIN_SCORE` is a hit. The
budget is counted in UTC days and kept in `reputation.json`, so a restart does
not make it whole again. The check that spends the last of it is logged and
alerted, and no client is checked until the day ends. The key is sent in the
`Key` header and nowhere else, and is never logged. A check fails, after which
no client is checked for a minute, when:
- AbuseIPDB answers other than `200`;
- the answer gives no score;
- no answer comes within `SWWAF_REPUTATION_TIMEOUT`.
- `SWWAF_REPUTATION_ACTION`, for DNS blocklist and AbuseIPDB hits, is `limit:25`
by default. Such verdicts are less certain than a blocklist, so a listed
client gets a quarter of every request and byte limit rather than a refusal.
- Verdicts and scores are kept for `SWWAF_REPUTATION_CACHE_TTL`, in memory and
in `reputation.json`. A source that is down or over quota gives no verdict.
The service continues. Each failure raises a `source_failure` alert (see
"Alerting").
### Alerting
- Alerts go to a webhook as JSON, and to Slack and ntfy as formatted messages.
`docs/alerts.md` lists the events, the JSON object and the messages.
`SWWAF_ALERT_EVENTS` chooses which of the seven event types are sent; the
hourly `summary` is sent whatever it says. The bans an admin makes raise no
alert. In `observe` mode, a request that would have made a ban still raises
the ban's alert, with `mode` set to `observe` in its `detail`.
- A repeat is an alert with the same event, and the same netblock, source, file
or anomaly scope, as one sent less than `SWWAF_ALERT_COOLDOWN` before. An
anomaly scope is one of the things counted under "Anomaly thresholds". A
repeat is held back and counted, and the next such alert sent gives the count.
- Past `SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour, the rest are held back and
counted by event, so a wide attack cannot flood the channel. Once the hour
ends, one alert with the event `summary` gives those counts. It also counts
the repeats that a cooldown held back and that no later alert reported.
- Each destination has a bounded queue (see "Fixed limits" in
`docs/settings.md`), sent one at a time, so a slow destination holds up
nothing. When a queue is full, its oldest alert is dropped and counted in the
metrics. A `2xx` answer takes an alert. A `4xx` other than `408` and `429`
refuses it, and it is dropped. Anything else is a failure: the alert is sent
again a second later, and twice as long after each further failure, up to a
minute. `alerts.json` keeps the queues and cooldowns across a restart.
### Anomaly thresholds
These only alert; nothing is blocked. None is set by default, and a threshold
left unset sends no alert. An alert needs a destination only the operator can
supply, and what counts as unusual depends on each service's normal traffic,
which the metrics show. They reveal a widely distributed scraper, thousands of
addresses at low rates each, which the per-client limits do not see. A low
`SWWAF_ASN_LIMIT_PERCENT` for its AS number is the response.
- Requests and bytes are counted per minute and per hour, for each of:
- a client;
- the surrounding netblock;
- an AS number;
- the whole service;
- a named netblock in `SWWAF_WATCH_NETS`.
- Every request `servewaf` answers with `allow`, `deny` or `close` is counted,
an allowed one when its report comes. That holds whatever was done with it,
and for clients in `SWWAF_ALLOW_NETS` and `SWWAF_RATE_LIMIT_EXEMPT_NETS`,
since an exempt client misbehaving is worth knowing about. The counters are
kept in memory and in `alerts.json`.
## Rule files
`servewaf` reads every `*.rules` file in `SWWAF_RULES_DIR` and checks each
request against them. `docs/rules.md` gives the format, the targets and actions,
and the default file.
- The files are plain text meant to be edited by hand, so that a new scanning
pattern seen in the request log becomes a rule in one line. `servewaf` watches
the directory: a file edited, added or removed there takes effect while it
runs. It reads the files again once the directory has had no change for 2
seconds, so that a file still being written is read whole. A file whose name
starts with `.`, such as an editor's lock file, is not a rule file.
- Files are read in name order (`00-default.rules` before `50-gitea.rules`),
rules in line order.
- A rule's regex is Go's RE2 syntax. It has no backreferences or lookaround; in
exchange matching time is linear in the input, so no rule can be made to stall
the proxy.
- `path` and `uri` are matched both as received and once percent-decoded, so an
encoded probe cannot slip past. A header sent more than once is matched with
its values joined by `, `; a header not sent is empty text. `header:Host` and
`header:Transfer-Encoding` cannot be targets, since Go's HTTP server takes
them out of every request; a rule naming one stops the start like any bad
line. The target `host` matches the request's host. Request bodies are not
available to rule files; reading them is the Core Rule Set's job.
- `log` only notes the match in the request log. `block` refuses the request;
`ban` makes the request a clear sign of attack (see "Bans"), and is for
requests no real visitor sends. A visitor banned by mistake is let back in by
lifting the ban.
- At start, these stop the process with a message naming the file and line:
- a line that does not parse;
- a regex that does not compile;
- a duplicate id.
So does a `SWWAF_RULES_DIR` that does not exist or cannot be read. While
running, the same faults leave the rules as they were, and the log and one
`file_error` alert name the file and line. Once the file is fixed, it is
read again. An empty directory is not an error: the log says that no rules
were loaded.
- The image ships a default file, kept short and limited to patterns that are
wrong for every app, with its path rules anchored at the site root. In front
of gitea, `/.env` is a probe, while
`/<owner>/<repo>/src/branch/main/.env.example` is a file in a repository that
any visitor may open. Anchor the paths of your own `ban` rules at the site
root too. Anything else, such as a rule banning WordPress probes in front of
gitea, belongs in a file the deployer adds to the image `servewaf` runs from,
or mounts. A rule has one target, so a `path` rule applies on every site
behind that `servewaf`. A directory mounted over `SWWAF_RULES_DIR` replaces
the default file; single files mounted into it add to it.
- Rules meant to be edited while `servewaf` runs belong in a mounted directory,
not a mounted file. Many editors save a file by replacing it, and a container
keeps seeing the old one when the file alone is mounted. The lookup database
is mounted the same way, for the same reason. To run without rule files, mount
an empty directory or set `SWWAF_RULES_ENABLED=false`.
## Persistent state
All state lives in memory, and the files in `SWWAF_STATE_DIR` hold a copy of it,
so an orderly stop loses nothing. The files are meant for people as well: an
admin can read or edit them at any time, and the running `servewaf` takes the
edit in. `docs/state-files.md` lists the files, what each holds, and their
sizes.
- Each file holds one kind of state, as JSON with:
- a top-level `version` number;
- entries sorted by client address;
- times in RFC 3339 UTC;
- durations and sizes as plain numbers, with the unit in the field name.
- `bans.json`, `reputation.json` and `alerts.json` are indented, so that a
person can open `bans.json` in an editor, find an address, and remove or add
an entry. `clients.json` and `lookups.json` put each entry on one line, which
halves their size and lets `grep` show everything about one client.
- Clients that were never banned are kept too, so every client's history
survives a restart.
- Each file has a bound, so it can be rewritten whole. Writing one takes well
under a second, in the background, so rewriting whole files needs nothing
cleverer.
- A write serialises from a snapshot taken under the lock, so requests are not
held up. It goes to a temporary file in the same directory, which is synced
and renamed over the real name, and the directory is synced. A crash at any
point leaves either the old complete file or the new complete file, never a
partial one. Before writing a file, `servewaf` takes in any edit made to it on
disk, so an admin's edit is never overwritten.
- At start, the files are read back. Active bans go into an in-memory lookup by
netblock, everything else into maps. Expired cache entries are discarded. A
missing file means empty state, and is normal on first run. These stop the
process with a message naming the file and position:
- a file that does not parse, or has an unknown `version`;
- an entry without a field it needs;
- a ban whose cause is not one of the four;
- a ban whose `expires` is not after its `start`.
Starting with empty state would silently forgive every repeat offender.
Since writes are atomic, a broken file can only come from a hand edit, which
the editor should hear about.
- While running, `servewaf` notices a file that is edited, replaced or added. It
tells its own writes from an admin's by comparing the file with what it last
read or wrote. An edit that parses is taken in at once: what the file says
replaces what `servewaf` held for that file. An edit to `bans.json` makes,
lifts or forgets bans as "Bans" describes. A change `servewaf` made after the
admin opened the file, such as a new ban, is lost when the admin saves over
it. Most editors warn when a file changed on disk while it was open. A file
that is removed is written again at its next write.
- An edit that does not parse never stops the running `servewaf`. It keeps the
state it has and sets the edited file aside as `<name>.bad`, so the edit is
kept. It writes the file again from memory, and names the file and the
position of the error in the log and in one `file_error` alert. The admin
mends the `.bad` file and moves it back.
- A write that fails leaves the state correct in memory. It is logged, counted
in the metrics and sent as one `file_error` alert per cooldown, and retried at
the next write.
- A hard kill loses the changes since the last write: ban changes from the last
`SWWAF_STATE_WRITE_DELAY`, everything else from the last
`SWWAF_STATE_COUNTER_INTERVAL`. Losing the volume loses ban history, not
service.
## Request log
`rproxy` and `servewaf` each write request lines on stdout, one JSON object to a
line, refused requests included. "The log" says which lines a request gets, and
`docs/request-log.md` lists the fields. Bodies are never logged, and no header
but those listed there. Query strings are logged as received; an app that
carries secrets in query strings needs that fixed in the app. Each process's own
messages (start, the settings, stop, errors) share its stream as JSON lines with
`"type":"process"`; request lines carry `"type":"request"`.
When `SWWAF_LOG_REMOTE_URL` is set, every line `servewaf` writes on stdout is
also sent there as an RFC 5424 syslog record. So a deployment can stop depending
on docker's log handling while `docker logs` keeps working. `rproxy` writes its
lines to stdout only.
- Over UDP each record is one datagram; a line too long for one datagram is
dropped alone. Over TCP and TLS each record follows its length in bytes and a
space. UDP gives no delivery signal at all and is offered only for
compatibility.
- The record carries the facility from `SWWAF_LOG_REMOTE_FACILITY`, the severity
informational, the time, the host's name and the app name from
`SWWAF_LOG_REMOTE_APP_NAME`.
- Sending happens on its own goroutine from a buffer of
`SWWAF_LOG_REMOTE_BUFFER` lines, so a server that is slow or down never holds
up a request and never stops stdout. When the buffer is full, the oldest line
is dropped and counted in the metrics. A failed connection is tried again a
second later, and twice as long after each further failure, up to a minute.
- As `servewaf` stops, it spends at most two seconds sending the lines still
waiting, and drops the rest; stdout has carried them.
## Metrics endpoint
`GET /_smallwebwaf/metrics`, at `servewaf`'s own address, answers in the
Prometheus text format, for a request carrying `SWWAF_METRICS_TOKEN`.
`docs/metrics.md` lists the metrics. No metric carries a client IP address as a
label. Per-address questions are answered by the request log and
`GET /_smallwebwaf/clients/<ip>`.
## Admin endpoints
`servewaf`'s own endpoints are paths under `/_smallwebwaf/`, at its own address
on the private network. Admins and the metrics collector reach them there, and
nothing routes the internet to that address. The prefix carries the tool's name,
so it takes no path an app uses. `docs/admin-endpoints.md` lists the endpoints,
what each takes and what each answers.
- `GET /_smallwebwaf/healthz` answers `200` with `ok` to anyone, without a
token, while `servewaf` is running.
- `POST /_smallwebwaf/report` takes `rproxy`'s report, without a token (see "The
report").
- Every other endpoint needs its token, sent as `Authorization: Bearer <token>`
(see "Tokens" under "Settings"). A missing or wrong token is answered `401`.
While a token is unset, the endpoints that need it answer `404`, as does any
other path.
- No check runs on these requests. They count toward no limit, and no client's
history holds them.
- A ban made through the endpoints starts at once.
`rproxy` answers `GET /_smallwebwaf/healthz` itself. It never passes a path
under the prefix to the app (see "Without an answer from `servewaf`").
## `servewaf`
`smallwebwaf servewaf` runs the checks for `smallwebwaf rproxy`, a small reverse
proxy in front of each app. `rproxy` asks `servewaf` about each visitor's
request. `servewaf` answers `rproxy` and passes nothing to an app itself. One
`servewaf` serves many sites.
This section is the agreement between the two. The header names, the fields and
the statuses below are exact.
`servewaf` reads the settings, the rule files, the lookup database and the state
files, and listens at `SWWAF_LISTEN_ADDR`. On `SIGTERM` or `SIGINT` it stops
taking requests and gives those in progress five seconds to finish. It then
writes the state files.
### The question
A request carrying `X-Smallwebwaf-Client` is `rproxy` asking about one visitor's
request. It is the visitor's request as the visitor sent it:
- the method;
- the path and query as sent;
- `Host`;
- every header the visitor sent, including its own `X-Forwarded-For`;
- the first 64 KiB of the body, or all of it when shorter.
Go's HTTP code changes some of the visitor's headers, such as:
- Go's client does not send an empty `User-Agent`.
- Go's server adds `Cache-Control: no-cache` to a request with
`Pragma: no-cache` and no `Cache-Control`, before `rproxy` sees it.
`rproxy` itself refuses any request it cannot send this way (see "Requests
`rproxy` refuses itself"). So a question always has:
- a path starting with one `/`;
- a `Host` that is not empty and holds no IPv6 zone;
- at most one `User-Agent`.
A body whose length the visitor did not announce comes chunked. Any other body
comes with a `Content-Length` of the part it carries.
`rproxy` sets these headers, replacing any the visitor sent, and adds no others:
- `X-Smallwebwaf-Client`: the visitor's address, which `rproxy` has worked out,
without any IPv6 zone. `servewaf` takes it as the client, and groups an IPv6
address as "What happens to a request" says.
- `X-Smallwebwaf-Peer`: the address of `rproxy`'s own TCP peer, for the log
line's `peer_ip`.
- `X-Smallwebwaf-Content-Length`: the body length the visitor announced. It is
absent when the visitor announced none.
- `X-Request-ID`: the request's id, which `rproxy` makes fresh for every request
when it arrives. `servewaf` takes it as given and never makes one.
- `X-Forwarded-Proto`: the scheme.
Before any check, `servewaf` removes the three `X-Smallwebwaf-` headers. It also
sets `Content-Length` to the length the visitor announced, or removes it. So the
rule files and the Core Rule Set see the headers the visitor sent, with
`rproxy`'s `X-Request-ID` and `X-Forwarded-Proto`.
`servewaf` refuses a question with `400` when:
- `X-Smallwebwaf-Client` is not an address, or is an address with a zone, such
as `2001:db8::1%eth0`;
- `X-Request-ID` is absent, empty, over 128 bytes or not valid UTF-8;
- `X-Request-ID` is the id of a question `servewaf` is still handling, or of a
request it holds (see "The report");
- the body is over 64 KiB.
For a refused question, `servewaf` writes a process warning. It runs no check,
writes no request log line and counts nothing in the metrics.
These do not refuse a question:
- `X-Smallwebwaf-Peer` absent or not an address: the log line's `peer_ip` is
`invalid IP`.
- `X-Smallwebwaf-Content-Length` not a whole number, or negative: it counts as
absent.
- `X-Forwarded-Proto` absent: the log line's `scheme` is `http`.
- One of the five headers above sent more than once: `servewaf` uses its first
value.
`servewaf` reads the whole body before any check. A question whose body stops
arriving gets no answer. `servewaf` closes the connection once the 60 seconds
below run out, or as soon as `rproxy` closes its side. It writes a process
warning, runs no check, writes no request log line and counts nothing in the
metrics.
### The answer
`servewaf` runs steps 3 to 13 of "What happens to a request", in that order.
Every answer but the last below carries `X-Smallwebwaf-Disposition`.
- `X-Smallwebwaf-Disposition: allow`, status `200`, no body. `rproxy` passes the
request to the app. With `SWWAF_ADD_LOOKUP_HEADERS` the answer also carries
`X-Client-ASN` and `X-Client-Country`, which `rproxy` adds to the request it
passes on. A header whose value is unknown is left out.
- `X-Smallwebwaf-Disposition: deny`, with the status, headers and body the
visitor is to get. `rproxy` gives them to the visitor, less this header. The
status is one of:
- `SWWAF_BAN_RESPONSE`, for a refusal;
- `403`, for a `block` rule or the Core Rule Set;
- `431`, for a request over the header limits;
- `404`, for a path under `/_smallwebwaf/` that passes the checks, so that
the app never sees that prefix.
- `X-Smallwebwaf-Disposition: close`, status `200`, no body. This takes the
place of a refusal while `SWWAF_BAN_RESPONSE` is `close`. `rproxy` closes the
visitor's connection without an answer.
- No such header, status `400`, for a question `servewaf` refuses. `rproxy`
gives the visitor its status, headers and body, as it does for any answer
without a valid `X-Smallwebwaf-Disposition`.
In `observe` mode a request that `enforce` mode would refuse is answered
`allow`, and its log line has `would_action`. A path under `/_smallwebwaf/` is
still answered `404`.
The Core Rule Set inspects the body a question carries only while
`SWWAF_WAF_BODY_LIMIT` is set. It inspects at most 64 KiB less one byte. So it
does not inspect a JSON or XML body of 64 KiB or more, which may have been cut
short.
### Answering in time
`rproxy` stops waiting for `servewaf` after `SWRP_TIMEOUT_MS`, by default 2
seconds, and `SWRP_DEFAULT_DISPOSITION` then decides. So at the defaults,
`servewaf` answers a request well within that time, whatever the request and
whatever its outside sources do.
GeoJS is the only outside source a question can wait on. A question waits only
when step 5 says so, and for at most `SWWAF_LOOKUP_TIMEOUT`, by default 800
milliseconds. A client without an answer by then counts as unknown. No question
waits on these, which run in the background:
- the DNS blocklists and AbuseIPDB;
- fetching the blocklists, the CrowdSec decision list and the file
`SWWAF_ASN_LIMIT_PERCENT_URL` names.
In `enforce` mode, a question does not wait for GeoJS when it is a request:
- over the header limits;
- from a client on a fetched blocklist, while `SWWAF_BLOCKLIST_ACTION` is
`deny`;
- from a client on the CrowdSec decision list;
- that would take its client over a rate limit at 100% of it, since a percentage
can only lower a limit.
Such a request still goes through the steps in their order. Its client's AS
number and country stay unknown until GeoJS has answered. So while
`SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` is set, that list can refuse it first.
`SWWAF_UNKNOWN_LIMIT_PERCENT` does not apply to it until then, so not waiting
never lowers its limit.
The static lists and bans come before the lookup, so they never wait. These can
still wait for GeoJS:
- a country list, which needs the answer;
- a rate limit a percentage has lowered, which needs the answer;
- a DNS blocklist or AbuseIPDB, while `SWWAF_REPUTATION_ACTION` is `deny`;
- a trap path;
- a rule file;
- the Core Rule Set.
The header limits are fixed, with no setting. `servewaf` refuses a request over
either one:
- more than 50 cookies. Each part of its `Cookie` headers between semicolons
counts as one, unless it is empty or only spaces and tabs.
- more than 100 header fields. A header sent twice counts twice. `Host` and
`Transfer-Encoding` do not count. `X-Request-ID` and `X-Forwarded-Proto` do.
Real clients never reach these limits. The answer is `deny` with `431`, and the
log line's action is `too_large`. The refusal makes no ban and is no offence.
The rate limits count the request as usual, so a client that keeps sending such
requests breaks a limit.
The limits apply on every path and in every `SWWAF_WAF_MODE`. A client in
`SWWAF_ALLOW_NETS` skips them, as it skips the Core Rule Set. In `observe` mode
such a request is answered `allow` with `would_action`, and the Core Rule Set
does not read it.
The Core Rule Set runs each of its rules on each cookie, so thousands of small
cookies would keep it busy for over a second. Within the limits, one long cookie
or query string can still keep it busy for a fraction of a second. So enough
such requests at once can overload `servewaf` past `SWRP_TIMEOUT_MS`.
### The report
Once the visitor's answer has ended, `rproxy` sends `POST /_smallwebwaf/report`
for each request `servewaf` answered with `allow`, `deny` or `close`. It sends
none after:
- an answer without a valid `X-Smallwebwaf-Disposition`, such as a `400`;
- an answer it cannot pass on (see "Without an answer from `servewaf`");
- no answer.
The report carries no `X-Smallwebwaf-Client`. Its body is one JSON object with
all of these fields, named as the request log's fields of the same meaning:
- `request_id`: the question's `X-Request-ID`;
- `status`: what the visitor got, `0` if nothing, otherwise from `100` to `599`;
- `request_bytes` and `response_bytes`: the body bytes each way, with what a
WebSocket carried each way, each a whole number from `0` to `1125899906842624`
(1 PiB);
- `duration_total`: milliseconds from the request's headers being read to the
end of the answer, from `0` to `1000000000000` (about 31 years);
- `duration_upstream_total`: milliseconds from the hand-over to the app to the
end of the answer, in the same range, and `0` for a request not passed to the
app.
`servewaf` answers `400` and writes a process warning when the body:
- is not one JSON object, such as `null`, or an object followed by other data;
- lacks one of these fields, or gives it as `null`;
- gives a value of the wrong type, or out of its range;
- is over 1 MiB.
It answers `204` to any other report, also for a request it does not hold, which
it ignores. It ignores a field not listed here. `rproxy` does not act on the
answer.
For a request it holds, `servewaf` writes the request's last log line (see "The
log"). For a request it allowed, it first:
- counts the bytes as `SWWAF_BYTES_COUNT` says, toward the client's byte limits
at the percentages it gave that request;
- bans a client that passes a byte limit;
- adds the request with its status to the client's history, the anomaly counters
and the metrics.
A denied request's counting is finished at its decision, so its report counts
nothing.
`servewaf` holds each answered request until its report comes, up to 10,000
requests. Past that, the request answered longest ago is dropped. A request
whose report never comes gets no last log line. If it was allowed, it is in
neither the client's history nor the metrics, and its bytes are not counted.
`servewaf` holds only the decision's log line and what counting needs. It never
holds the request or response objects, and no `Authorization` or `Cookie`. A
report reaches only what is held, so it can never reach a question still being
handled. `servewaf` ignores a report naming such a question.
### The log
A request `servewaf` answers with `allow`, `deny` or `close` gets three request
log lines, joined by `request_id`:
- `rproxy`'s short line;
- `servewaf`'s line at its decision;
- `servewaf`'s line when the report comes, with everything known: the decision
line's fields and the report's together.
The repetition is intended. The field `servewaf_line` says which of `servewaf`'s
two lines a line is. A request `servewaf` is not asked about, such as the health
check, gets only `rproxy`'s line. A request without a report gets no third line.
`docs/request-log.md` gives the fields.
### `servewaf`'s own endpoints
A request without `X-Smallwebwaf-Client` is for `servewaf` itself, at its own
address on the private network. The internet must not be able to reach that
address. Its endpoints are:
- the health check, the metrics, and the ban and client endpoints, as
`docs/admin-endpoints.md` lists them, with their tokens;
- the report.
Anything else gets `404`. No check runs on these requests, since their peers are
`rproxy` and the admin's tools. The report needs no token, so anything on the
private network can send one or ask a question.
A request and its answer have 60 seconds together, from the request's headers.
`servewaf` closes a connection idle for 120 seconds. It takes a request line and
headers up to 1 MiB, Go's default, so that it never refuses a question for the
size of a visitor's headers.
### Sites
One `servewaf` keeps one table of clients, counters and bans for every site that
asks it. A client banned through one site is refused on all of them, and its
requests on all of them count toward one set of limits. Log lines, alerts raised
by a request and ban notes name the site by its host.
`docs/metrics.md` says how its metrics count.
## `rproxy`
`smallwebwaf rproxy` is the reverse proxy in front of one app. It asks
`servewaf` about each request, and passes on what `servewaf` allows. It keeps
nothing between requests and never touches disk. When `servewaf` cannot be
connected to, does not answer at all, or does not answer within
`SWRP_TIMEOUT_MS`, `SWRP_DEFAULT_DISPOSITION` decides. Its default, `allow`,
keeps the site answering.
`rproxy` reads only its `SWRP_` settings, which `docs/settings.md` lists. One of
them is required: `SWRP_WAF_BACKEND`, `servewaf`'s URL. It has no metrics, no
rule files and no state files, and it writes its lines to stdout only. The image
runs it by default (see "Deployment").
### What `rproxy` does with a request
1. `GET /_smallwebwaf/healthz` is answered `ok` at once, without asking
`servewaf`.
2. `rproxy` works out the visitor's address. It believes `X-Forwarded-For` only
from `SWRP_TRUSTED_PROXIES`, and reads it as step 2 of "What happens to a
request" says. It removes any IPv6 zone.
3. It makes a fresh request id, whatever id the request came with. `servewaf`
and the app get it as `X-Request-ID`, and `rproxy`'s log line gives it.
4. It refuses a request with a method it does not pass on, or one it cannot put
to `servewaf` as the visitor sent it (see "Requests `rproxy` refuses
itself").
5. It reads up to the first 64 KiB of the body into memory.
6. It sends `servewaf` the question (see "The question"). It speaks HTTP/1.1 to
`servewaf`, whatever `servewaf` offers. Go's HTTP/2 client would refuse to
send a visitor's `Upgrade` header, and most `Connection` headers. The
question never carries the visitor's `Trailer`.
7. It does what the answer says (see "The answer"):
- `allow`: it passes the request to the app.
- `close`: it closes the visitor's connection without an answer.
- Any other answer: the visitor gets its status, headers and body, less
`X-Smallwebwaf-Disposition`. That holds for a `deny`, for an error such as
`servewaf`'s `400` or a `5xx`, and for an answer without a valid
`X-Smallwebwaf-Disposition`.
8. Once the answer has ended, it writes its log line. `docs/request-log.md`
gives the fields. An app's answer can end just after the line: Go's server
sends the closing chunk of a chunked answer, and may send all of an empty
one, only once `rproxy` has finished with the request.
9. It then sends the report, if `servewaf` answered `allow`, `deny` or `close`
(see "The report"). The visitor does not wait for it. A report that fails is
dropped, with a process warning. A status above `599` from the app goes to
the visitor as it is, and into the report as `599`, the highest `servewaf`
takes.
### Requests `rproxy` refuses itself
`rproxy` passes on only these methods:
- `GET`
- `HEAD`
- `POST`
- `OPTIONS`
- `PUT`
- `DELETE`
- `PATCH`
- `QUERY`
It answers any other method `405`, with no question and no report. The answer's
`Allow` header names these methods.
`rproxy` answers `400`, with no question and no report, to a request with:
- a target that does not start with `/`, such as `x:admin`,
`http://site.example/` or `site.example:443`;
- a target that starts with `//`;
- no `Host`, or an empty one;
- a `Host` holding an IPv6 zone: one that starts with `[` and holds a `%`, such
as `[fe80::1%25eth0]:80`;
- more than one `User-Agent`.
Go's HTTP client could not send any of these as the visitor sent it.
Go's HTTP server answers `OPTIONS *` itself, before `rproxy` sees it.
`rproxy` closes the connection without an answer when it cannot read the first
64 KiB of the body. That happens for a malformed chunk, and for a visitor that
goes away or stops its sending side. There is no question and no report, and the
log line has status `0`.
### Without an answer from `servewaf`
`SWRP_DEFAULT_DISPOSITION` decides what is done with the request only when
`servewaf`:
- cannot be connected to;
- does not answer at all;
- does not answer within `SWRP_TIMEOUT_MS`, which covers connecting, sending the
question and reading the whole answer.
An answer whose body breaks off counts as no answer at all.
`allow`, the default, passes the request to the app. `deny` answers `503`.
Either way `rproxy` writes a process warning, its log line has
`default_disposition`, and it sends no report.
A visitor can make `servewaf` slow. A request that keeps `servewaf` busy past
`SWRP_TIMEOUT_MS`, like any request while `servewaf` is overloaded, gets
`SWRP_DEFAULT_DISPOSITION`. With `deny`, such a request gets `503` instead of
reaching the app.
A path under `/_smallwebwaf/` that `allow` would pass on is answered `404`
instead, so that the app never sees that prefix.
`rproxy` cannot pass on an answer whose body is over 1 MiB or whose status is
below `200`. The visitor gets `502` from `rproxy`, never the default. `rproxy`
writes a process warning, its log line has no `disposition`, and it sends no
report.
A visitor that goes away while `servewaf` is being asked is no failure of
`servewaf`. `rproxy` closes the connection, with no warning, no default and no
report. Its log line has status `0` and no `disposition`.
### Passing a request to the app
`rproxy` sends the app at `SWRP_UPSTREAM_URL` the visitor's request: the part of
the body it holds, then the rest, streaming. The app's answer comes back the
same way. Apart from what Go's HTTP code changes, the request reaches the app
unchanged but for these headers:
- `X-Forwarded-For`, with the address of `rproxy`'s TCP peer added, when that
peer is inside `SWRP_TRUSTED_PROXIES`. The peer's other forwarded headers pass
on.
- For a peer outside `SWRP_TRUSTED_PROXIES`, its forwarded headers are its own
claims and are replaced. `X-Forwarded-For` names the peer, `X-Forwarded-Host`
the host it asked for, and `X-Forwarded-Proto` plain `http`. The other headers
in which traefik tells an app about a visitor are removed.
- `X-Request-ID`, which carries the fresh id.
- `X-Client-ASN` and `X-Client-Country`, which carry what `servewaf` answered. A
header it did not give is left out. Any of the two the visitor sent is
removed.
- The headers that concern only the connection to `rproxy` never reach the app:
- `Connection`, and any header it names;
- `Keep-Alive`;
- `Proxy-Authenticate`;
- `Proxy-Authorization`;
- `Proxy-Connection`;
- `Te`, though one holding `trailers` goes on as `Te: trailers`;
- `Upgrade`.
A chunked body's trailers reach the app, but not `servewaf`.
A WebSocket upgrade passes through, with `Connection: Upgrade` and its
`Upgrade`. The report of a WebSocket comes when it closes, with what it carried
each way.
### `rproxy`'s limits
These limits apply per request, in both directions:
- `SWRP_MAX_REQUEST_SIZE` is the largest request body.
- `SWRP_MAX_RESPONSE_SIZE` is the largest response body.
- `SWRP_MAX_REQUEST_TIME_MS` bounds the request line and headers. From their
end, it then bounds the time until the app has been sent the whole request.
The wait for `servewaf` counts toward it.
- `SWRP_MAX_RESPONSE_TIME_MS` runs from the end of the request to the last byte
of the answer reaching the visitor. The request ends when the app has been
sent all of it, or when the app's answer arrives if that is sooner. An answer
`rproxy` gives itself has the whole time from when it starts.
The largest request line and headers is 32 KiB. A kept-open connection is closed
after 120 seconds idle. Neither has a setting. traefik, by default, closes an
unused connection after 90 seconds. So traefik closes first, and never sends a
request on a connection `rproxy` is closing.
What the visitor gets when a limit is passed:
- Go's HTTP server reads a request's line and headers before `rproxy` sees the
request. It answers `431` to headers over 32 KiB. A visitor too slow to send
them gets an error answer, mostly `400`, or none. Either way the server closes
the connection, nothing reaches the app, and the request log gets no line.
- A visitor too slow to send the first 64 KiB of its body is answered `408`,
with no question and no report.
- `rproxy` applies the request size limit only after `servewaf` has allowed the
request. So a request `servewaf` denies gets `servewaf`'s answer, whatever its
size. A request `servewaf` allows is answered `413` when its body is over the
limit: at once for a length it announced, otherwise when the limit is reached.
If part of the body has gone to the app by then, the request to the app is cut
off.
- A limit passed after that, but before the response has started, is answered by
`rproxy`:
- `408` for a visitor too slow to send the rest of its body;
- `502` for a response that is too large, or an app that cannot be reached;
- `504` for an app that is too slow.
- A request body streams through, so each side can hold up the other.
`SWRP_MAX_REQUEST_TIME_MS` can run out while the body is on its way. The
answer is then `408` if `rproxy` was waiting for the visitor, and `504` if it
was waiting for the app.
- Once the response has started it can only be cut off, and the connection is
closed.
- A WebSocket connection leaves these limits behind once it is upgraded. It
stays open until either side closes it.
### Starting and stopping
An invalid setting, or an address it cannot listen on, stops the start with a
process line. The process lines give the host's name as `instance`, and only
those at `info` or more severe are written.
On `SIGTERM` or `SIGINT`, `rproxy` stops taking requests and gives those in
progress five seconds to finish. It then waits for the reports on their way,
each for up to `SWRP_TIMEOUT_MS`. A report that comes due after that is dropped,
such as that of a WebSocket that closes later.
## Deployment
One image runs either command. `SMALLWEBWAF_COMMAND`, in a container's
environment, chooses: `servewaf` runs `servewaf`, and with it unset the image
runs `rproxy`. `docs/image.md` lists what the image holds and how each part is
pinned.
### The usual setup
The usual setup is three containers on one private network:
- the app;
- `rproxy`, which traefik routes to, with `SWRP_UPSTREAM_URL` naming the app and
`SWRP_WAF_BACKEND` naming `servewaf`;
- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume for its state
files.
Each further app adds its own container and its own `rproxy`, whose
`SWRP_WAF_BACKEND` names the same `servewaf` (see "Sites"). Anything on that
network can reach an app around `rproxy`, and can ask `servewaf` questions or
send it reports. So the network holds only what you trust.
### `rproxy` in the app's container
An app's Dockerfile can build `FROM` the `smallwebwaf` image instead, so that
`rproxy` runs beside the app in one container. The app is then hardened by
changing its base image, and can add packages from nixpkgs. `servewaf` still
runs in a container of its own, and the app's container sets `SWRP_WAF_BACKEND`.
Beyond its `FROM` line, the app's Dockerfile adds:
- its binary;
- any packages it needs, from nixpkgs, with `nix-env -iA nixpkgs.<name>`;
- its runit service: a directory under `/etc/service` named after the app, never
`smallwebwaf`. Its `run` script starts the app with `chpst`, listening where
`SWRP_UPSTREAM_URL` points (see "Ports"). The app runs as a user of its own,
which the Dockerfile creates with `useradd`. That user is neither root nor
`smallwebwaf`, so that the app cannot touch the process of `rproxy`.
It sets no `ENTRYPOINT` or `USER` of its own. The container must start
`runsvinit`, as root, so that it can start each service as its own user.
"Getting started" in `README.md` shows such a Dockerfile and `run` script.
### The image
- The image is Ubuntu's newest long-term support release, pinned by digest, and
moves to the next LTS release when that ships. Its packages come from Ubuntu's
snapshot service, the archive as it was at the moment the Dockerfile names,
rather than the live archive, whose packages change with every update. The
snapshot is never older than the pinned Ubuntu image, since older packages can
need older versions of packages the image holds, and it moves forward with the
image's digest. The Dockerfile names the hash of each of the snapshot's
`InRelease` files, which cover every package list and so every package, and
the build checks them before installing. The live archive's `InRelease` files,
which `apt-get update` also fetches and the install does not use, are left out
of the check. The hashes are the amd64 archive's, so the image is built for
amd64 only.
- The Ubuntu image has no CA certificates. Without them Nix cannot download
packages and `servewaf` cannot reach GeoJS, so `ca-certificates` is installed
by name. The snapshot itself is fetched over HTTPS with the CA certificates of
the Go image that builds `smallwebwaf`, itself pinned by digest.
- nixpkgs comes from a release file on `releases.nixos.org`, named with its
hash, not from a GitHub archive, whose bytes can change. Root uses Nix
directly; no Nix daemon runs. `nix-bin` creates no build users, so
`/etc/nix/nix.conf` sets `build-users-group =`, which lets root build without
them. Root's Nix profile is last on the `PATH`, so that no package hides the
image's own commands; busybox's `sv`, for one, looks for services elsewhere.
An app built on the image gets the same packages each time. Security fixes
from Ubuntu and nixpkgs come with a newer `smallwebwaf` image, which an app
takes by changing the digest in its `FROM` line. The image moves to each new
nixpkgs release, every six months.
- runit supervises a container's processes, with `runsvinit` as the entrypoint,
as the owner's code style guide asks for service containers. No package
carries `runsvinit`, so the image builds it from its archived source,
unchanged since 2015, at a fixed commit hash, with a `go.mod` the build
writes. `runsvinit` starts runit's `runsvdir`, which runs the `run` script of
each directory under `/etc/service`. Ubuntu's `sv` looks for services there
too.
- The image's `run` script is `/etc/service/smallwebwaf/run`. It starts
`servewaf` when `SMALLWEBWAF_COMMAND` is `servewaf`, and `rproxy` when it is
`rproxy`, unset or empty. It runs either as the `smallwebwaf` user (uid and
gid 65532), with runit's `chpst`. For `servewaf`, it first makes
`SWWAF_STATE_DIR` and every file in it belong to that user. For `rproxy` it
changes no owner. With any other value, it starts nothing, and stops with a
message naming `SMALLWEBWAF_COMMAND` and its value.
Every `run` script, the app's included, is a bash script. It starts with
`#!/usr/bin/env bash` and `set -euo pipefail`, and puts its code in a `main`
function, called on its last line, as the owner's code style guide asks. Ubuntu
ships bash. Each waits one second (`sleep 1`) before starting its program, so a
process that stops at start is started again every second, not in a tight loop.
### Each container's processes
- `runsvinit`, `runsvdir` and each service's `runsv` run as root. Each `run`
script starts as root and hands over to its program's user with `chpst`. Every
service starts with the container's environment variables and writes to the
container's output, which `docker logs` shows.
- When a service exits, runit runs its `run` script again, and the container
does not stop. Beside an app, the other process keeps running.
- While `rproxy` is down, traefik answers `502`. While the app is down, `rproxy`
answers `502`, which is not a client offence. While `servewaf` is down,
`SWRP_DEFAULT_DISPOSITION` decides (see "Without an answer from `servewaf`").
- Only faults found at start stop `servewaf`:
- invalid configuration;
- a lookup database that is missing or unreadable;
- a rule file or state file that does not parse;
- an unwritable `SWWAF_STATE_DIR`;
- a `SWWAF_RULES_DIR` that does not exist or cannot be read;
- a `SWWAF_LISTEN_ADDR` it cannot listen on.
- Only an invalid setting, or a `SWRP_LISTEN_ADDR` it cannot listen on, stops
`rproxy`.
- A process that stops at start logs its message at each try until the cause is
fixed; its container's health check fails meanwhile. Once running, a failing
GeoJS, reputation source or alert destination, or a broken edit, never takes
`servewaf` down.
- `docker stop` sends `SIGTERM` to `runsvinit`, which has runit stop every
service at once, each with `SIGTERM`, and exits once they have stopped.
`rproxy` and `servewaf` then stop as "`rproxy`" and "`servewaf`" say.
- With `rproxy` beside the app, an app that exits at once on `SIGTERM` ends the
requests in progress: the visitor gets `502`, or a response cut short. To let
them finish, the app must keep answering for five seconds after `SIGTERM`.
- The container's root filesystem stays writable: runit writes each service's
status into its directory under `/etc/service`.
### The health check
The image's `HEALTHCHECK` runs `smallwebwaf healthcheck`. `SMALLWEBWAF_COMMAND`
says what it checks:
- With `SMALLWEBWAF_COMMAND=servewaf`, it passes while `servewaf` answers
`GET /_smallwebwaf/healthz` on `127.0.0.1`, at the port in
`SWWAF_LISTEN_ADDR`.
- With `SMALLWEBWAF_COMMAND` unset, empty or `rproxy`, it passes while `rproxy`
answers that request on `127.0.0.1`, at the port in `SWRP_LISTEN_ADDR`, and
the app accepts connections at `SWRP_UPSTREAM_URL`. It does not ask
`servewaf`, so that the site stays in traefik's routes while `servewaf` is
down.
- With any other value, it fails with a message naming `SMALLWEBWAF_COMMAND` and
its value.
traefik sends a container no requests until it shows as healthy. So the check
runs every second from the container's start until it first passes, for up to a
minute, and every 30 seconds after that. An app with a health check of its own
can replace the image's `HEALTHCHECK` with one that checks both.
### Ports
`rproxy` listens on no port but the one in `SWRP_LISTEN_ADDR`, 8080 by default,
which traefik routes to. It passes requests to the app at `SWRP_UPSTREAM_URL`,
`http://127.0.0.1:8081` by default. Another port may be set: the health check
follows the setting, and traefik's port label
(`traefik.http.services.<name>.loadbalancer.server.port`) must name it. An app
beside `rproxy` must leave that port free.
`servewaf` listens on no port but the one in `SWWAF_LISTEN_ADDR`, 8080 by
default. The questions, the reports and its own endpoints all come there.
The address part of either setting stays empty (`:9000`, never
`127.0.0.1:9000`). That way the others reach the process on the container's
address, and the health check reaches it on `127.0.0.1`.
An app beside `rproxy` listens on that loopback address only. An app that
listens on every address can be reached around `rproxy` by anything that reaches
the container.
### State
`servewaf` keeps its state files in `SWWAF_STATE_DIR`, belonging to the
`smallwebwaf` user. Without a volume there, the files survive a restart of the
container and are lost when a deploy replaces it. A volume mounted at
`/var/lib/smallwebwaf`, named or a host directory, keeps them across deploys. A
host directory mounted there needs no change of owner, and files an earlier
owner left in it can be read and replaced. The volume holds a few tens of MiB at
most with the defaults, and needs no backup beyond the host's own. The image
declares no volume, since every app image built on it would inherit it. `rproxy`
keeps nothing, and needs no volume.
### Tokens
A token given as a file (`SWWAF_ADMIN_TOKEN_FILE`, `SWWAF_METRICS_TOKEN_FILE`)
stays out of the container's environment. The operator makes the file on the
host, owned by uid 65532, the `smallwebwaf` user, with mode `0400`, and mounts
the directory that holds it into `servewaf`'s container read-only. The container
sees the same owner and mode. "Settings given as files" in `docs/settings.md`
shows the commands.
### Forwarded headers
The app's TCP peer is `rproxy`, and the `X-Forwarded-For` the app receives ends
with traefik's address, which `rproxy` adds after the visitor's. So the app must
trust `rproxy`'s address for forwarded headers, besides the private address
ranges traefik is on.
- In a container of its own, `rproxy`'s address is on the private network. The
usual default for a trusted-proxy setting, the private ranges, covers it.
- Beside the app, `rproxy`'s address is `127.0.0.1` or `::1`. The private ranges
leave out loopback: an app left at them ignores the header and sees every
visitor as `127.0.0.1`. A list given replaces that default, so the app's own
trusted-proxy setting names them all:
`10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32,::1/128`.
### upaas
upaas rebuilds and restarts an app's Docker container when its repository
changes. An app it deploys takes the shape of "`rproxy` in the app's container"
with no change to upaas. upaas builds the app's Dockerfile and runs the one
container as it runs any app. The traefik labels route to port 8080, and the
`SWRP_` settings go with the app's environment variables. `servewaf` runs in a
container of its own, on a network the app's container can reach, with its state
volume and the directory of any token file. `docs/gitea.md` records what the
defaults do in front of gitea, and `script/gitea-requests` checks those notes.
## Planned, not built
This point is specified as later work, and nothing in the code does it yet:
- Planned: sending the log over RELP, the reliable syslog transport. It is
revisited against the available Go packages. Until then the remote log goes
over UDP, TCP or TLS as described under "Request log".