`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. Model: opus-5-5
91 KiB
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 rproxyis a reverse proxy between traefik and one app.smallwebwaf servewafdecides 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.
rproxyrequires only the address ofservewaf. 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 withSWWAF_, and those ofrproxywithSWRP_. The only other inputs areservewaf'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_FILEnames.
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
rproxyand 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
servewafwrites 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 callsinternal/smallwebwaf.internal/smallwebwaf: the process, as the command it is given.smallwebwaf servewafreads the settings, the rule files, the lookup database and the state files, listens, and serves untilSIGTERMorSIGINT. Then it stops, writing the state files (see "servewaf").smallwebwaf rproxyreads only that command's settings, and runsinternal/rproxyuntilSIGTERMorSIGINT(see "rproxy").smallwebwaf healthcheckis 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 incheck, and holdsservewaf's question, answer and report. It answersservewaf's own endpoints inanswerOwnEndpoint. It counts bytes, refusals and anomalies, and writes the log lines.internal/rproxy: the reverse proxy in front of one app that asksservewafabout each request. It passes a request to the app with the standard library'shttputil.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/requestlogandinternal/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-appis an app built on the image, withrproxyin its container, whichscript/example-appchecks withservewafin 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/v3andgithub.com/corazawaf/coraza-coreruleset/v4atv4.25.0, which carries the Core Rule Set 4.25.0. The changessmallwebwafmakes to it, and the defaultSWWAF_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/v2for the bounded tables;github.com/oschwald/maxminddb-golang/v2to read the lookup database;github.com/fsnotify/fsnotifyto notice edited files;github.com/prometheus/client_golangfor 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/syslogis frozen and writes only the older syslog format.
- the standard library for the reverse proxy, HTTP clients, DNS, logging
(
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.
-
GET /_smallwebwaf/healthzis answered at once byrproxy, before any check, so a health checker never needs an exemption and is never refused. -
The client is identified.
- If the TCP peer is inside
SWRP_TRUSTED_PROXIES,X-Forwarded-Foris 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. rproxythen sendsservewafthe question (see "The question").servewaftakes the address the question gives as the client.- An IPv6 client is its group: the netblock of
SWWAF_IPV6_GROUP_PREFIXaround 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.
- If the TCP peer is inside
-
The static lists. A client in
SWWAF_ALLOW_NETSskips steps 4 to 12, the byte limits and the error burst. A client inSWWAF_DENY_NETSis refused; a client in both lists is let through. -
Bans. A client under a ban is refused (see "Bans").
-
The lookup, unless
SWWAF_LOOKUP_SOURCEisoff(see "Lookup of AS number and country"). The request goes on at once. It waits for the first GeoJS answer, up toSWWAF_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.
-
The country lists (see "Country lists").
-
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 leastSWWAF_ABUSEIPDB_MIN_SCOREby AbuseIPDB, getsSWWAF_REPUTATION_ACTION. -
The client's limit percentage (see "Biased thresholds").
-
Rate limiting (see "Request rate limits, per client"). A request over a limit times the percentage is refused and bans the client.
-
The trap paths (see "Attack detection"). A request for one is refused and bans the client.
-
The rule files (see "Rule files"). Matching stops at the first rule that refuses.
-
The header limits (see "Answering in time"), and then the Core Rule Set (see "Attack detection"). A Core Rule Set match is refused with
403whileSWWAF_WAF_MODEisblock, and only logged and alerted while it isdetect. -
A path under
/_smallwebwaf/is answered404, so that the app never sees that prefix. -
rproxypasses the request toSWRP_UPSTREAM_URL, streaming (see "Passing a request to the app"). WithSWWAF_ADD_LOOKUP_HEADERS,X-Client-ASNandX-Client-Countryare 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. -
After the visitor's answer has ended,
rproxysends the report (see "The report").servewafthen counts:- For a request it allowed, the response bytes, the request bytes or both
are added to the byte counters, as
SWWAF_BYTES_COUNTsays. 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,
servewafadds 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"). - For a request it allowed, the response bytes, the request bytes or both
are added to the byte counters, as
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.jsonand 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 bySWWAF_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" indocs/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 inSWWAF_TRAP_PATHS. Examples are a probe for a.envfile or a.gitdirectory, and a known scanner's user agent.- The first one bans the netblock for
SWWAF_ATTACK_BAN_DURATION(default7d), with the causeattack. - 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.
- The first one bans the netblock for
-
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_THRESHOLDrefusals within a minute.
The bans for a broken limit:
- The first such ban lasts
SWWAF_LIMIT_BAN_DURATION(default1h), with the causelimit. So does one that comes more thanSWWAF_LIMIT_BAN_REPEAT_WINDOW(default24h) 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(default7d) 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_SOURCEandSWWAF_WAF_MODE;- the three rate limits and the three byte limits;
SWWAF_WAF_ANOMALY_THRESHOLD,SWWAF_WAF_BODY_LIMITandSWWAF_ERROR_BURST_THRESHOLD;SWWAF_ALERT_COOLDOWNandSWWAF_ALERT_MAX_PER_HOUR;- every anomaly threshold;
SWRP_MAX_REQUEST_SIZE,SWRP_MAX_RESPONSE_SIZE,SWRP_MAX_REQUEST_TIME_MSandSWRP_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_FILEnames the file that holdsSWWAF_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_FILEalready holds a file name, so it has no_FILEform. NoSWRP_setting has one, sincerproxynever touches disk. -
Settings, files included, are read once at start. Changing one means restarting the container. The files
servewafwatches 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_NAMEis in each lineservewaflogs, 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=observelets a configuration be tried before it is enforced (see "What happens to a request").SWWAF_IPV6_GROUP_PREFIXsets the group size. After a change, each IPv6 client starts afresh; existing bans keep refusing their netblocks until they end.SWWAF_MAX_TRACKED_CLIENTSandSWWAF_MAX_BANSset the bounds under "Counting method" and "Bans".
Tokens
SWWAF_ADMIN_TOKENswitches on the ban endpoints and/_smallwebwaf/clients/<ip>. The metrics are off untilSWWAF_METRICS_TOKENis set, since without a token they would be open to anything onservewaf'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_DIRnames the directory of the state files (see "State" under "Deployment").bans.jsonis writtenSWWAF_STATE_WRITE_DELAYafter a ban is made, lifted or made permanent, so a burst of changes becomes one write. Every file is written everySWWAF_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_LEVELfilters only the process's own messages, and no message is atdebug.warnanderrorhold back the line of settings at start. SWWAF_LOG_REQUEST_HEADERSnames the request headers to record.Authorization,CookieandSet-Cookievalues are never logged, even if listed. The log says only whetherAuthorizationandCookiewere present.SWWAF_LOG_REMOTE_URLnames a syslog server (see "Request log").
Metrics
SWWAF_METRICS_TOP_Nis how many of the busiest AS numbers and countries get a series of their own. The rest are summed asother, so the label set stays bounded.
Static lists
SWWAF_ALLOW_NETSskips the checks step 3 names, for monitoring or the owner's own networks.SWWAF_RATE_LIMIT_EXEMPT_NETSbypasses 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_NETSorSWWAF_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
fileis set withoutSWWAF_LOOKUP_DB_PATH, orSWWAF_LOOKUP_DB_PATHwith any other source. The start also stops when the source isoffand a setting that needs a lookup is set (docs/settings.mdlists 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_NETSorSWWAF_DENY_NETS; - one under a ban;
- a private, loopback or link-local address, which no source can place.
- one in
- 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'scountry_code,asnandorganization_nameare read. An answer without acountry_codeleaves the country unknown, and theasn64512leaves 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_TIMEOUTis 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 asource_failurealert (see "Alerting"). A service that cannot accept this uses the file. - The file is the free IPinfo Lite database (
ipinfo_lite.mmdb), whoseasn,as_nameandcountry_codefields are read. The operator downloads it with a free IPinfo account and mounts it read-only;servewafnever 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_erroralert 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_COUNTRIESrefuses every request from a listed country.SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIESlets 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 inSWWAF_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_COUNTRIESthen 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 actioncountry_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.0is a zero allowance: the client's first request breaks a limit and bans it. SWWAF_ASN_BYTES_PERCENTandSWWAF_COUNTRY_BYTES_PERCENTset the byte limits' percentage alone, when it should differ from the request limits'. For an AS number they list, they replace its percentage fromSWWAF_ASN_LIMIT_PERCENTandSWWAF_ASN_LIMIT_PERCENT_URL; for a country, the one fromSWWAF_COUNTRY_LIMIT_PERCENT.SWWAF_ASN_LIMIT_PERCENT_URLnames a text file ofAS:percentlines, so one abuse-source list can be shared by everyservewafin the fleet. It is fetched and kept like a blocklist (see "Reputation"). Where it andSWWAF_ASN_LIMIT_PERCENTboth 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 getsSWWAF_UNKNOWN_LIMIT_PERCENTin 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_LEVELandSWWAF_WAF_ANOMALY_THRESHOLDare 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 thresholdoff, 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, inblockanddetectmode alike, the log line gives the rule ids and the score, and awaf_blockalert is sent. -
SWWAF_WAF_DISABLED_RULESswitches 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
.shor.sql; - 930130 and 930140 refuse a file or directory name in the path, such as
.git/,.gitignore,Dockerfile,package.jsonor 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_LIMITswitches body inspection on, up to that size. It takes at most64K, sinceservewafgets 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.mdrecords what it refuses in front of gitea. -
SWWAF_TRAP_PATHSnames paths the app never serves and only scanners ask for, such as/wp-login.phpin front of gitea. This is the short form of apathrule with thebanaction, 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.phpmatches/wp-login.php?x=1and/%77p-login.php, and does not match/wp-login.php/or/blog/wp-login.php. -
SWWAF_ERROR_BURST_THRESHOLDcounts 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
404and401by 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:
-
PUT,PATCHandDELETEare allowed methods besidesGET,HEAD,POSTandOPTIONS. APIs, container image pushes and package uploads use them. Other methods stay refused. -
The request header
Expectis allowed, since curl and git send it before some large uploads.Content-Encodingis 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 asProxy, stay refused. -
The
redirect_uriparameter is not checked for a URL naming an IP address orlocalhost(931100, 934110). Git Credential Manager, git-credential-oauth and tea, which gitea registers for OAuth sign-in, ask to be sent back tohttp://127.0.0.1on the user's own machine. The server never fetches that address. -
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-toandsub_path;ref,sha,branchandworkflow;artifactNameandredirect_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
.gitignoreordocker-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. -
Two cookies are left out, and
Refererkeeps fewer rules:- The Core Rule Set reads the request without the
gitea_flashandredirect_tocookies. gitea puts a message naming what was just done intogitea_flash. It puts intoredirect_tothe page to return to after signing in. The Core Rule Set takes file names in them, such aspackage.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. Refereris not checked for a Unix command given without arguments (932340) or for Java starting a process (944110).Referernames 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 forenv, or OpenJDK'sRuntime.java, whose path holds bothruntimeandjava.. All of those requests would be refused.Refererkeeps the other rules. An app that passesRefererto a shell must check it itself.
- The Core Rule Set reads the request without the
-
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/120is 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_REFRESHis never less than1h, and the last good copy is kept whole, comment lines included, on failure and across restarts. -
Each list is fetched again
SWWAF_BLOCKLIST_REFRESHafter its last try, one list at a time.reputation.jsonkeeps 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.
- the server answers other than
-
SWWAF_BLOCKLIST_ACTIONisdenyby 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.logonly 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, withSWWAF_CROWDSEC_LAPI_KEYin theX-Api-Keyheader. The list is kept like a fetched blocklist, and a client on it is banned (see "Bans"). Only decisions of the typebanon an address or a netblock are used; any other, such as a captcha or a country, is left out. A decision lasts for thedurationit had left when the engine answered. This is how a fleet-wide blocklist can arrive withoutservewafdepending 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.
- the engine answers other than
-
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. So192.0.2.99is asked about as99.2.0.192.dnsbl.dronebl.org. An answer in127.0.0.0/8lists the client, but one in127.255.255.0/24is 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.
servewafshows the key as********wherever it names the zone. Onlyreputation.jsonkeeps the key.SWWAF_DNSBL_RESOLVERnames a resolver, since public resolvers are refused by several list operators. - an answer in
-
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 overSWWAF_ABUSEIPDB_MIN_SCOREis a hit. The budget is counted in UTC days and kept inreputation.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 theKeyheader 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.
- AbuseIPDB answers other than
-
SWWAF_REPUTATION_ACTION, for DNS blocklist and AbuseIPDB hits, islimit:25by 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 inreputation.json. A source that is down or over quota gives no verdict. The service continues. Each failure raises asource_failurealert (see "Alerting").
Alerting
- Alerts go to a webhook as JSON, and to Slack and ntfy as formatted messages.
docs/alerts.mdlists the events, the JSON object and the messages.SWWAF_ALERT_EVENTSchooses which of the seven event types are sent; the hourlysummaryis sent whatever it says. The bans an admin makes raise no alert. Inobservemode, a request that would have made a ban still raises the ban's alert, withmodeset toobservein itsdetail. - 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_COOLDOWNbefore. 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_HOURalerts 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 eventsummarygives 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. A2xxanswer takes an alert. A4xxother than408and429refuses 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.jsonkeeps 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
servewafanswers withallow,denyorcloseis counted, an allowed one when its report comes. That holds whatever was done with it, and for clients inSWWAF_ALLOW_NETSandSWWAF_RATE_LIMIT_EXEMPT_NETS, since an exempt client misbehaving is worth knowing about. The counters are kept in memory and inalerts.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.
servewafwatches 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.rulesbefore50-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.
-
pathanduriare 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:Hostandheader:Transfer-Encodingcannot 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 targethostmatches the request's host. Request bodies are not available to rule files; reading them is the Core Rule Set's job. -
logonly notes the match in the request log.blockrefuses the request;banmakes 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_DIRthat does not exist or cannot be read. While running, the same faults leave the rules as they were, and the log and onefile_erroralert 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,
/.envis a probe, while/<owner>/<repo>/src/branch/main/.env.exampleis a file in a repository that any visitor may open. Anchor the paths of your ownbanrules 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 imageservewafruns from, or mounts. A rule has one target, so apathrule applies on every site behind thatservewaf. A directory mounted overSWWAF_RULES_DIRreplaces the default file; single files mounted into it add to it. -
Rules meant to be edited while
servewafruns 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 setSWWAF_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
versionnumber; - entries sorted by client address;
- times in RFC 3339 UTC;
- durations and sizes as plain numbers, with the unit in the field name.
- a top-level
-
bans.json,reputation.jsonandalerts.jsonare indented, so that a person can openbans.jsonin an editor, find an address, and remove or add an entry.clients.jsonandlookups.jsonput each entry on one line, which halves their size and letsgrepshow 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,
servewaftakes 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
expiresis not after itsstart.
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.
- a file that does not parse, or has an unknown
-
While running,
servewafnotices 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 whatservewafheld for that file. An edit tobans.jsonmakes, lifts or forgets bans as "Bans" describes. A changeservewafmade 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 onefile_erroralert. The admin mends the.badfile 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_erroralert 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 lastSWWAF_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 fromSWWAF_LOG_REMOTE_APP_NAME. - Sending happens on its own goroutine from a buffer of
SWWAF_LOG_REMOTE_BUFFERlines, 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
servewafstops, 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/healthzanswers200withokto anyone, without a token, whileservewafis running.POST /_smallwebwaf/reporttakesrproxy'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 answered401. While a token is unset, the endpoints that need it answer404, 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-cacheto a request withPragma: no-cacheand noCache-Control, beforerproxysees 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
Hostthat 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, whichrproxyhas worked out, without any IPv6 zone.servewaftakes it as the client, and groups an IPv6 address as "What happens to a request" says.X-Smallwebwaf-Peer: the address ofrproxy's own TCP peer, for the log line'speer_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, whichrproxymakes fresh for every request when it arrives.servewaftakes 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-Clientis not an address, or is an address with a zone, such as2001:db8::1%eth0;X-Request-IDis absent, empty, over 128 bytes or not valid UTF-8;X-Request-IDis the id of a questionservewafis 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-Peerabsent or not an address: the log line'speer_ipisinvalid IP.X-Smallwebwaf-Content-Lengthnot a whole number, or negative: it counts as absent.X-Forwarded-Protoabsent: the log line'sschemeishttp.- One of the five headers above sent more than once:
servewafuses 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, status200, no body.rproxypasses the request to the app. WithSWWAF_ADD_LOOKUP_HEADERSthe answer also carriesX-Client-ASNandX-Client-Country, whichrproxyadds 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.rproxygives them to the visitor, less this header. The status is one of:SWWAF_BAN_RESPONSE, for a refusal;403, for ablockrule 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, status200, no body. This takes the place of a refusal whileSWWAF_BAN_RESPONSEisclose.rproxycloses the visitor's connection without an answer.- No such header, status
400, for a questionservewafrefuses.rproxygives the visitor its status, headers and body, as it does for any answer without a validX-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_URLnames.
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_ACTIONisdeny; - 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_ACTIONisdeny; - 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
Cookieheaders 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.
HostandTransfer-Encodingdo not count.X-Request-IDandX-Forwarded-Protodo.
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 a400; - 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'sX-Request-ID;status: what the visitor got,0if nothing, otherwise from100to599;request_bytesandresponse_bytes: the body bytes each way, with what a WebSocket carried each way, each a whole number from0to1125899906842624(1 PiB);duration_total: milliseconds from the request's headers being read to the end of the answer, from0to1000000000000(about 31 years);duration_upstream_total: milliseconds from the hand-over to the app to the end of the answer, in the same range, and0for 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_COUNTsays, 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.mdlists 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
GET /_smallwebwaf/healthzis answeredokat once, without askingservewaf.rproxyworks out the visitor's address. It believesX-Forwarded-Foronly fromSWRP_TRUSTED_PROXIES, and reads it as step 2 of "What happens to a request" says. It removes any IPv6 zone.- It makes a fresh request id, whatever id the request came with.
servewafand the app get it asX-Request-ID, andrproxy's log line gives it. - It refuses a request with a method it does not pass on, or one it cannot put
to
servewafas the visitor sent it (see "Requestsrproxyrefuses itself"). - It reads up to the first 64 KiB of the body into memory.
- It sends
servewafthe question (see "The question"). It speaks HTTP/1.1 toservewaf, whateverservewafoffers. Go's HTTP/2 client would refuse to send a visitor'sUpgradeheader, and mostConnectionheaders. The question never carries the visitor'sTrailer. - 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 adeny, for an error such asservewaf's400or a5xx, and for an answer without a validX-Smallwebwaf-Disposition.
- Once the answer has ended, it writes its log line.
docs/request-log.mdgives 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 oncerproxyhas finished with the request. - It then sends the report, if
servewafansweredallow,denyorclose(see "The report"). The visitor does not wait for it. A report that fails is dropped, with a process warning. A status above599from the app goes to the visitor as it is, and into the report as599, the highestservewaftakes.
Requests rproxy refuses itself
rproxy passes on only these methods:
GETHEADPOSTOPTIONSPUTDELETEPATCHQUERY
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 asx:admin,http://site.example/orsite.example:443; - a target that starts with
//; - no
Host, or an empty one; - a
Hostholding 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 ofrproxy's TCP peer added, when that peer is insideSWRP_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-Fornames the peer,X-Forwarded-Hostthe host it asked for, andX-Forwarded-Protoplainhttp. The other headers in which traefik tells an app about a visitor are removed. X-Request-ID, which carries the fresh id.X-Client-ASNandX-Client-Country, which carry whatservewafanswered. 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
rproxynever reach the app:Connection, and any header it names;Keep-Alive;Proxy-Authenticate;Proxy-Authorization;Proxy-Connection;Te, though one holdingtrailersgoes on asTe: 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_SIZEis the largest request body.SWRP_MAX_RESPONSE_SIZEis the largest response body.SWRP_MAX_REQUEST_TIME_MSbounds the request line and headers. From their end, it then bounds the time until the app has been sent the whole request. The wait forservewafcounts toward it.SWRP_MAX_RESPONSE_TIME_MSruns 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 answerrproxygives 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
rproxysees the request. It answers431to headers over 32 KiB. A visitor too slow to send them gets an error answer, mostly400, 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. rproxyapplies the request size limit only afterservewafhas allowed the request. So a requestservewafdenies getsservewaf's answer, whatever its size. A requestservewafallows is answered413when 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:408for a visitor too slow to send the rest of its body;502for a response that is too large, or an app that cannot be reached;504for an app that is too slow.
- A request body streams through, so each side can hold up the other.
SWRP_MAX_REQUEST_TIME_MScan run out while the body is on its way. The answer is then408ifrproxywas waiting for the visitor, and504if 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, withSWRP_UPSTREAM_URLnaming the app andSWRP_WAF_BACKENDnamingservewaf;servewaf, withSMALLWEBWAF_COMMAND=servewafand 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/servicenamed after the app, neversmallwebwaf. Itsrunscript starts the app withchpst, listening whereSWRP_UPSTREAM_URLpoints (see "Ports"). The app runs as a user of its own, which the Dockerfile creates withuseradd. That user is neither root norsmallwebwaf, so that the app cannot touch the process ofrproxy.
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
InReleasefiles, which cover every package list and so every package, and the build checks them before installing. The live archive'sInReleasefiles, whichapt-get updatealso 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
servewafcannot reach GeoJS, soca-certificatesis installed by name. The snapshot itself is fetched over HTTPS with the CA certificates of the Go image that buildssmallwebwaf, 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-bincreates no build users, so/etc/nix/nix.confsetsbuild-users-group =, which lets root build without them. Root's Nix profile is last on thePATH, so that no package hides the image's own commands; busybox'ssv, 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 newersmallwebwafimage, which an app takes by changing the digest in itsFROMline. The image moves to each new nixpkgs release, every six months. - runit supervises a container's processes, with
runsvinitas the entrypoint, as the owner's code style guide asks for service containers. No package carriesrunsvinit, so the image builds it from its archived source, unchanged since 2015, at a fixed commit hash, with ago.modthe build writes.runsvinitstarts runit'srunsvdir, which runs therunscript of each directory under/etc/service. Ubuntu'ssvlooks for services there too. - The image's
runscript,/etc/service/smallwebwaf/run, starts the commandSMALLWEBWAF_COMMANDnames,rproxywhen it is unset or empty, as thesmallwebwafuser (uid and gid 65532) with runit'schpst. Forservewaf, it first makesSWWAF_STATE_DIRand every file in it belong to that user. Forrproxyit changes no owner. With any other value,smallwebwafprints its commands and exits, and the health check fails.
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,runsvdirand each service'srunsvrun as root. Eachrunscript starts as root and hands over to its program's user withchpst. Every service starts with the container's environment variables and writes to the container's output, whichdocker logsshows.- When a service exits, runit runs its
runscript again, and the container does not stop. Beside an app, the other process keeps running. - While
rproxyis down, traefik answers502. While the app is down,rproxyanswers502, which is not a client offence. Whileservewafis down,SWRP_DEFAULT_DISPOSITIONdecides (see "Without an answer fromservewaf"). - 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_DIRthat does not exist or cannot be read; - a
SWWAF_LISTEN_ADDRit cannot listen on.
- Only an invalid setting, or a
SWRP_LISTEN_ADDRit cannot listen on, stopsrproxy. - 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
servewafdown. docker stopsendsSIGTERMtorunsvinit, which has runit stop every service at once, each withSIGTERM, and exits once they have stopped.rproxyandservewafthen stop as "rproxy" and "servewaf" say.- With
rproxybeside the app, an app that exits at once onSIGTERMends the requests in progress: the visitor gets502, or a response cut short. To let them finish, the app must keep answering for five seconds afterSIGTERM. - 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 whileservewafanswersGET /_smallwebwaf/healthzon127.0.0.1, at the port inSWWAF_LISTEN_ADDR. - Otherwise it passes while
rproxyanswers that request on127.0.0.1, at the port inSWRP_LISTEN_ADDR, and the app accepts connections atSWRP_UPSTREAM_URL. It does not askservewaf, so that the site stays in traefik's routes whileservewafis down.
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 is127.0.0.1or::1. The private ranges leave out loopback: an app left at them ignores the header and sees every visitor as127.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
An app deployed by upaas, which rebuilds and restarts an app's Docker container
when its repository changes, 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".