Author SHA1 Message Date
clawbot 6f9086439c deploy/gitea: Gitea behind traefik, rproxy and servewaf, with its test (closes #178)
check / check (push) Waiting to run
deploy/gitea/compose.yaml runs traefik, stock Gitea 1.27.3, rproxy and
servewaf on one network with a fixed subnet. Its containers skip
servewaf's checks; the network's gateway is left out of that range.
Gitea takes a secret key made once per install, and its sign-up is off.
rproxy takes uploads of 2 GiB and 30 minutes, and cuts responses at 15
minutes. script/gitea-setup (make gitea-setup) brings the setup up and
checks it. docs/gitea.md is rewritten around the setup.

Judgement call: the Core Rule Set runs in detect mode, so it refuses nothing.
Judgement call: traefik serves its own certificate; no certificate resolver.
Partially verified: the image build failed three runs on Ubuntu's snapshot
service, so the checks ran only against a stand-in image with the same binary.

Model: deepseek-v4-pro (implementation), opus-5-5 (rework)
2026-11-06 18:18:02 +00:00
10 changed files with 628 additions and 130 deletions
+6 -2
View File
@@ -1,12 +1,13 @@
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \ .PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
run example-app gitea-requests run example-app gitea-requests gitea-setup
# Makefile targets are thin shims; the implementations live in script/ # Makefile targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does, # of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
# which test checks. build and run are for working on the code by hand; # which test checks. build and run are for working on the code by hand;
# example-app checks the image with an app built on it; gitea-requests # example-app checks the image with an app built on it; gitea-requests
# reports the gitea requests the Core Rule Set refuses at the defaults. # reports the gitea requests the Core Rule Set refuses at the defaults;
# gitea-setup checks the setup in deploy/gitea.
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -49,3 +50,6 @@ example-app:
gitea-requests: gitea-requests:
@script/gitea-requests @script/gitea-requests
gitea-setup:
@script/gitea-setup
+31 -19
View File
@@ -26,10 +26,10 @@ It protects against:
- Scrapers that fetch every page of a site, all day. Each client gets a limit on - Scrapers that fetch every page of a site, all day. Each client gets a limit on
requests and on bytes per minute, per hour and per day. The defaults leave requests and on bytes per minute, per hour and per day. The defaults leave
room for a person reading a site for hours. They catch a scraper that keeps up room for programmatic use of an API, such as an automation fleet, and catch a
one request every 2 seconds. A client over a limit is refused and banned: for client firing requests as fast as it can within seconds. A client over a limit
a while at first, for three times as long at each repeat, and permanently when is refused and banned: for a while at first, for three times as long at each
it keeps at it. repeat, and permanently when it keeps at it.
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The - Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
default rule file bans such a client at its first probe, and its next request default rule file bans such a client at its first probe, and its next request
during the ban makes the ban permanent. Your own rules, or a list of trap during the ban makes the ban permanent. Your own rules, or a list of trap
@@ -237,13 +237,13 @@ Every setting the code reads, with its default:
| `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. | | `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. | | `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
| `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. | | `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWWAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. | | `SWWAF_RATE_LIMIT_PER_MINUTE` | `500` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
| `SWWAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. | | `SWWAF_RATE_LIMIT_PER_HOUR` | `20000` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
| `SWWAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. | | `SWWAF_RATE_LIMIT_PER_DAY` | `300000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
| `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. | | `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
| `SWWAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. | | `SWWAF_BYTES_LIMIT_PER_MINUTE` | `4G` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
| `SWWAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. | | `SWWAF_BYTES_LIMIT_PER_HOUR` | `20G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
| `SWWAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. | | `SWWAF_BYTES_LIMIT_PER_DAY` | `200G` | The most bytes a client's requests may carry in a day, a size or `off`. |
| `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. | | `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
| `SWWAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. | | `SWWAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
| `SWWAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. | | `SWWAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
@@ -408,10 +408,10 @@ services:
SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7 SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
# A netblock that sends nothing wanted. # A netblock that sends nothing wanted.
SWWAF_DENY_NETS: 198.51.100.128/25 SWWAF_DENY_NETS: 198.51.100.128/25
# Tighter limits for everyone else: a small static site. # Tighter limits for everyone else: a site with no API.
SWWAF_RATE_LIMIT_PER_MINUTE: "60" SWWAF_RATE_LIMIT_PER_MINUTE: "120"
SWWAF_RATE_LIMIT_PER_HOUR: "600" SWWAF_RATE_LIMIT_PER_HOUR: "3000"
SWWAF_RATE_LIMIT_PER_DAY: "3000" SWWAF_RATE_LIMIT_PER_DAY: "30000"
# Hosting networks and one country get a share of those limits. # Hosting networks and one country get a share of those limits.
SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25 SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
SWWAF_COUNTRY_LIMIT_PERCENT: cn:25 SWWAF_COUNTRY_LIMIT_PERCENT: cn:25
@@ -432,9 +432,10 @@ volumes:
gitea behind traefik, set up for a code forge: gitea behind traefik, set up for a code forge:
- the upload limits raised, in size and in time; - the upload limits raised, in size and in time;
- the response size limit raised, for clones over 1 GiB; - the response size limit and the byte limits raised, for clones over 1 GiB;
- the Actions runners exempt from counting; - the Actions runners exempt from counting;
- the default request and byte limits kept. - the default request limits kept, since they already leave room for API
clients.
`traefik` is the network traefik is on, made outside this file. `traefik` is the network traefik is on, made outside this file.
@@ -488,6 +489,11 @@ services:
SWWAF_INSTANCE_NAME: fsn1/servewaf SWWAF_INSTANCE_NAME: fsn1/servewaf
# The Actions runners ask for work all day; never count them. # The Actions runners ask for work all day; never count them.
SWWAF_RATE_LIMIT_EXEMPT_NETS: 10.0.4.0/24 SWWAF_RATE_LIMIT_EXEMPT_NETS: 10.0.4.0/24
# Raised with rproxy's response limit, to four times the default
# each, so that one clone of that size breaks no byte limit.
SWWAF_BYTES_LIMIT_PER_MINUTE: 16G
SWWAF_BYTES_LIMIT_PER_HOUR: 80G
SWWAF_BYTES_LIMIT_PER_DAY: 800G
# gitea never serves these; a request for one bans the client. # gitea never serves these; a request for one bans the client.
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php,/wp-admin/ SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php,/wp-admin/
volumes: volumes:
@@ -505,8 +511,9 @@ volumes:
servewaf-state: servewaf-state:
``` ```
[`docs/gitea.md`](docs/gitea.md) records what the defaults do in front of gitea, `deploy/gitea` holds a complete setup of gitea, traefik included, as a Docker
request by request. Compose file. [`docs/gitea.md`](docs/gitea.md) explains it, and records what the
defaults do in front of gitea, request by request.
### `rproxy` beside the app ### `rproxy` beside the app
@@ -779,6 +786,10 @@ on the host.
Core Rule Set is known to refuse, and prints what `servewaf` did with each. Core Rule Set is known to refuse, and prints what `servewaf` did with each.
Needs network access, and a docker daemon that pushes to a registry on Needs network access, and a docker daemon that pushes to a registry on
`localhost` over plain HTTP. `make gitea-requests`. `localhost` over plain HTTP. `make gitea-requests`.
- `script/gitea-setup`: builds the image, brings up the setup in `deploy/gitea`,
and checks it as [`docs/gitea.md`](docs/gitea.md) describes. Then it removes
the containers, the network, the volumes and the image it built. Needs network
access. `make gitea-setup`.
## TODO ## TODO
@@ -802,7 +813,8 @@ on the host.
- [`docs/state-files.md`](docs/state-files.md): the state files and what each - [`docs/state-files.md`](docs/state-files.md): the state files and what each
holds. holds.
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned. - [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
- [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea. - [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea: the setup
in `deploy/gitea`, and what the defaults do there.
## License ## License
+29 -25
View File
@@ -397,31 +397,32 @@ Conventions:
### Request rate limits, per client ### Request rate limits, per client
Breaking one bans the client. The defaults let people read a site and stop Breaking one bans the client. The defaults suit programmatic use of an API, such
scrapers that send many requests slowly over hours or days. The hour limit is as an automation fleet on another host, and still stop abusive bots. A fleet of
the main one. 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: 120, 2 requests a second. It leaves room for a person opening - Per minute: 500, about 8 requests a second. It leaves room for the fleet's 120
several pages at once with an empty cache. A gitea page opened with an empty a minute and a few quick first visits with an empty cache. A gitea page opened
cache loads 20 to 60 files, and a handful once cached. with an empty cache loads about 40 files. A client firing requests as fast as
- Per hour: 1,500. A person reading code in gitea for a whole hour, a page every it can passes the limit within seconds.
20 seconds, makes about 900 requests. A scraper making one request every 2 - Per hour: 20,000, about 5.5 requests a second for a whole hour. A client that
seconds makes 1,800 an hour, more than the limit. keeps up 6 a second makes 21,600 in an hour, and passes it, at 360 a minute,
- Per day: 6,000, four hours at the hour limit. A scraper making one request under the minute limit. The fleet's 7,200 an hour is about a third of it.
every 10 seconds makes 8,640 a day, more than the limit. - 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, about 4 Counting cannot catch a client that stays under all three limits, such as one
requests a minute around the clock. Only the other checks can catch an abuser 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 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. 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
Some legitimate clients pass the limits: 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
- An Actions runner asks gitea for work all day and can pass the limits. Actions runners on one address do. Such a machine's address belongs in
- Everyone behind one shared address, such as an office, shares one client's `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
limits.
Their addresses belong in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` takes path prefixes out of the count, such as `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 static assets or health checks; their bytes are not counted either. A prefix is
@@ -434,9 +435,12 @@ matched the same way.
### Byte limits, per client ### Byte limits, per client
There is no minute limit, so a client can download in a burst at full speed. The The hour and day limits sit about twice above what 40 people busy with heavy web
hour limit is 50 GiB and the day limit 100 GiB. They allow several downloads of apps move together on one address. Each moves about 256 MiB an hour, for ten
a few GiB each. A response's bytes are counted when it ends (see step 15). 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 ### Lookup of AS number and country
+135
View File
@@ -0,0 +1,135 @@
# Gitea behind traefik, with rproxy and servewaf in front of it.
# docs/gitea.md says what each setting here does and why. Before starting it:
# - set SMALLWEBWAF_IMAGE to the smallwebwaf image, named by digest;
# - replace git.example.org with the site's host name;
# - replace each 10.99.0 with the first three numbers of a /24 subnet that
# no other network on the host uses. The /24 must lie inside 10.0.0.0/8,
# 172.16.0.0/12 or 192.168.0.0/16, because rproxy believes
# X-Forwarded-For only from those by default. Replace each fd10:99 with
# the first two hextets of an unused /64 subnet whose third and fourth
# hextets are 0, as in fd10:99::/64;
# - set GITEA_SECRET_KEY to the output of
# `docker run --rm <gitea's image below> gitea generate secret SECRET_KEY`,
# once per install, and keep it.
# Then run `docker compose up --detach` in this directory.
# script/gitea-setup checks this setup.
name: gitea
x-logging: &logging
driver: json-file
options:
max-size: 10m
max-file: "5"
services:
traefik:
# traefik v3.7.17, 2026-11-03
image: traefik@sha256:68ae5fe5b64ca166704b746a9e8afcd385e36b3f569a5691401bf652d57b3748
# traefik replaces the X-Forwarded-For header a visitor sends. Keep it
# so: otherwise a visitor could name an address on the shared network,
# and skip servewaf's checks.
command:
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.websecure.address=:443
# Until a certificate resolver is added here, as for your other
# sites, traefik answers with a certificate it made itself.
- --entrypoints.websecure.http.tls=true
# traefik's own limit of 60 seconds for a whole request would cut
# off an upload long before rproxy's limit of 30 minutes.
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=30m
ports:
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
shared:
# Containers on the shared network find traefik under the
# site's name, and so reach it from their own address.
aliases:
- git.example.org
restart: unless-stopped
gitea:
# gitea 1.27.3, 2026-08-29
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
environment:
GITEA__server__ROOT_URL: https://git.example.org/
# Gitea starts at its defaults, with SQLite, instead of showing its
# install page to whoever opens it first.
GITEA__security__INSTALL_LOCK: "true"
GITEA__security__SECRET_KEY: ${GITEA_SECRET_KEY:?set GITEA_SECRET_KEY, see docs/gitea.md}
# Accounts are made with `gitea admin user create`.
GITEA__service__DISABLE_REGISTRATION: "true"
# rproxy's addresses. Gitea then takes each visitor's address from
# the X-Real-Ip header traefik sets and rproxy passes on.
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.99.0.2,fd10:99::2
volumes:
- gitea-data:/data
networks:
- shared
restart: unless-stopped
rproxy:
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://gitea:3000
# Pushes, LFS objects, packages and release attachments of up to
# 2 GiB, sent in up to 30 minutes.
SWRP_MAX_REQUEST_SIZE: 2G
SWRP_MAX_REQUEST_TIME_MS: "1800000"
# 15 minutes for a clone or a download, half the default.
SWRP_MAX_RESPONSE_TIME_MS: "900000"
labels:
traefik.enable: "true"
traefik.http.routers.gitea.rule: Host(`git.example.org`)
traefik.http.routers.gitea.entrypoints: websecure
traefik.http.services.gitea.loadbalancer.server.port: "8080"
networks:
shared:
ipv4_address: 10.99.0.2
ipv6_address: fd10:99::2
logging: *logging
restart: unless-stopped
servewaf:
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
environment:
SMALLWEBWAF_COMMAND: servewaf
# The containers on the shared network skip the checks. Add the
# address of an automation fleet on another host here too; without
# that, the default request limits are sized for such a fleet.
SWWAF_ALLOW_NETS: 10.99.0.128/25,fd10:99:0:0:8000::/65
# In block mode the Core Rule Set refuses everyday Gitea searches
# and file names, and 31 refusals in a minute ban the address.
SWWAF_WAF_MODE: detect
volumes:
- servewaf-state:/var/lib/smallwebwaf
networks:
- shared
logging: *logging
restart: unless-stopped
networks:
# The network traefik and Gitea share. Its containers get addresses in the
# upper half of each subnet, which servewaf lets skip the checks. Each
# subnet's gateway, the host's own address on it, stays out of that half:
# requests docker passes on from a published port can come from it.
shared:
enable_ipv6: true
ipam:
config:
- subnet: 10.99.0.0/24
ip_range: 10.99.0.128/25
gateway: 10.99.0.1
- subnet: fd10:99::/64
ip_range: fd10:99:0:0:8000::/65
gateway: fd10:99::1
volumes:
gitea-data:
servewaf-state:
+170 -40
View File
@@ -1,48 +1,184 @@
# smallwebwaf in front of gitea # smallwebwaf in front of gitea
These notes record what `smallwebwaf` does in front of gitea at the default `deploy/gitea/compose.yaml` runs gitea behind traefik, with `rproxy` and
settings. They are observations, not requirements; the requirements are in `servewaf` in front of it. This document says what that setup sets and why, and
[`SPEC.md`](../SPEC.md). `script/gitea-requests` runs gitea behind `rproxy` and what the defaults would do without it. [`SPEC.md`](../SPEC.md) says how
`servewaf`, and sends through them a sample of gitea's ordinary use and the `smallwebwaf` behaves.
requests named below. It reports each request the Core Rule Set refuses, so that
these notes can be checked again when gitea or the Core Rule Set changes
version.
## What the defaults cover ## The setup
Four containers share one Docker network, the shared network:
- traefik, which routes the site's HTTPS requests to `rproxy`, and redirects
plain HTTP to HTTPS;
- `rproxy`, in front of gitea;
- gitea 1.27.3, as its image comes;
- `servewaf`, with a volume for its state files.
To start it:
1. In `deploy/gitea/compose.yaml`, replace `git.example.org` with the site's
host name.
2. Replace each `10.99.0` with the first three numbers of a `/24` subnet that no
other network on the host uses. The `/24` must lie inside `10.0.0.0/8`,
`172.16.0.0/12` or `192.168.0.0/16`, because `rproxy` believes
`X-Forwarded-For` only from those by default. Replace each `fd10:99` with the
first two hextets of an unused `/64` subnet whose third and fourth hextets
are 0, as in `fd10:99::/64`.
3. Set `SMALLWEBWAF_IMAGE` to the `smallwebwaf` image, named by digest.
4. Set `GITEA_SECRET_KEY` to the output of
`docker run --rm <gitea's image> gitea generate secret SECRET_KEY`, with
gitea's image as `deploy/gitea/compose.yaml` names it. Do this once per
install, and keep the key.
5. Run `docker compose up --detach` in `deploy/gitea`.
6. Make gitea's first user with `gitea admin user create`, in gitea's container,
as the user `git`.
Sign-up is off: accounts are made with `gitea admin user create`. gitea starts
at its defaults, with SQLite. traefik answers with a certificate it made itself
until you add a certificate resolver, as for your other sites. SSH to gitea does
not pass through `smallwebwaf`; publish gitea's SSH port yourself if you use it.
`make gitea-setup` checks the setup. It builds the image, brings the setup up
under a name of its own, and checks:
- that a page load, a push and a clone sent through traefik pass through
`rproxy`, and that `servewaf` checks them;
- that gitea logs the visitor's address, not `rproxy`'s;
- that a request from a container on the shared network skips the checks;
- that with `servewaf` stopped, gitea still answers through `rproxy`.
## Uploads and downloads
An upload is a request:
- SSH access to gitea does not pass through `smallwebwaf` and is not protected
by it.
- A clone is a response. It fits the defaults of 30 minutes and 1 GiB unless the
repository is large or the link slow. A repository whose clone is larger than
1 GiB needs `SWRP_MAX_RESPONSE_SIZE` raised.
- A push is a request, and so is every other upload:
- a git push; - a git push;
- an LFS object; - an LFS object;
- a container image layer; - a container image layer;
- a package file; - a package file;
- a release attachment. - a release attachment.
At the defaults, an upload larger than 100 MiB or taking more than 60 The setup lets an upload of up to 2 GiB, sent in up to 30 minutes, through:
seconds is cut off ("`rproxy`'s limits" in `SPEC.md`). A gitea that takes
large uploads needs `SWRP_MAX_REQUEST_SIZE` and `SWRP_MAX_REQUEST_TIME_MS`
raised to fit.
- Archive download and blame or history pages are what scrapers hammer. The - `SWRP_MAX_REQUEST_SIZE` is `2G`. At the default, an upload over 100 MiB is cut
request limits do most of the work there. off.
- An Actions runner that reaches gitea through `smallwebwaf` sends requests all - `SWRP_MAX_REQUEST_TIME_MS` is `1800000`, 30 minutes. At the default, an upload
day. One older than version 0.4 (April 2026) asks for work every 2 seconds, that takes over 60 seconds is cut off.
1,800 requests an hour. While it runs a job, it reports the job's log and - traefik's `readTimeout` is 30 minutes. traefik's own default of 60 seconds for
state every second instead, two requests a second. So one idle runner makes a whole request would cut off an upload first.
1,800 requests an hour, more than the hour limit of 1,500. A busy one, at two
requests a second, passes the minute limit of 120 within two minutes. Its
address is then banned, its running jobs fail, and each repeat within a day
makes the ban longer. Newer runners ask for work less often. Put the runners'
addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
## What the Core Rule Set lets through A clone or a download is a response:
At the defaults, the Core Rule Set lets gitea's ordinary use through, apart from - `SWRP_MAX_RESPONSE_TIME_MS` is `900000`, 15 minutes, half the default. A clone
the refusals in the next section: or download that takes longer is cut off.
- `SWRP_MAX_RESPONSE_SIZE` stays at its default, 1 GiB. A repository whose clone
is larger needs it raised, and the byte limits with it ("Byte limits, per
client" in `SPEC.md`).
The byte limits stay at their defaults. Two uploads of 2 GiB from one address in
one minute pass the minute's limit of 4 GiB, and ban the address.
## The checks
`SWWAF_WAF_MODE` is `detect`. The Core Rule Set then logs a match, with its rule
ids, and refuses nothing. In `block` mode, the default, it refuses everyday
gitea searches and file names. The section "What the Core Rule Set refuses in
`block` mode" below lists them. Each such refusal counts toward the error burst,
so 31 in one minute ban the address.
Every other check stays at its default. A client may make 500 requests a minute,
20,000 an hour and 300,000 a day. Archive downloads and blame and history pages
are what scrapers hammer, and these limits do most of the work there.
## The shared network
Containers on the shared network skip `servewaf`'s checks:
- The shared network's subnets are `10.99.0.0/24` and `fd10:99::/64`. Docker
gives its containers addresses in the upper half of each, `10.99.0.128/25` and
`fd10:99:0:0:8000::/65`, which `SWWAF_ALLOW_NETS` names.
- traefik is known on the shared network by the site's host name. So a container
there that asks for the site reaches traefik from its own address. An
automation fleet in such a container skips the checks.
- Each subnet's gateway, `10.99.0.1` and `fd10:99::1`, is the host's own address
on it. Requests docker passes on from a published port can come from the
gateway, so the gateways are left out of `SWWAF_ALLOW_NETS`. The network has
IPv6, so a visitor over IPv6 reaches traefik from its own address, and
`servewaf` checks it as itself.
- A container on the shared network that asks for the site at the host's public
address arrives from the gateway. It is checked like any visitor.
- A fleet on another host can have its address added to `SWWAF_ALLOW_NETS`.
Without that, the default request limits are sized for such a fleet.
- traefik replaces the `X-Forwarded-For` a visitor sends, by default. Keep it
so: otherwise a visitor could name an address on the shared network, and skip
the checks.
- Anything on the shared network can reach gitea and `servewaf` around `rproxy`.
Put only containers you trust on it.
## gitea's settings
- `REVERSE_PROXY_TRUSTED_PROXIES` names `rproxy`'s fixed addresses, `10.99.0.2`
and `fd10:99::2`. gitea then takes each visitor's address from the `X-Real-Ip`
header, which traefik sets and `rproxy` passes on. Without it, gitea logs
every visitor as `rproxy`.
- `INSTALL_LOCK` is `true`, as "The setup" above says.
## Methods
`rproxy` passes on only these methods: `GET`, `HEAD`, `POST`, `OPTIONS`, `PUT`,
`DELETE`, `PATCH` and `QUERY`. gitea 1.27.3 needs no other. Its routes use:
- pages and the API: `GET`, `HEAD`, `POST`, `PUT`, `PATCH` and `DELETE`;
- git's push, clone and fetch over HTTP: `GET`, `POST` and `OPTIONS`;
- LFS: `GET`, `POST` and `PUT`;
- the container registry and the other package registries: `GET`, `HEAD`,
`POST`, `PUT`, `PATCH` and `DELETE`;
- Actions runners: `POST`.
## The log
`rproxy` and `servewaf` write each query string to the log whole. A token sent
in the query string, as `?token=`, is then in the log. Send it in the
`Authorization` header instead.
## Actions runners
A runner on the shared network skips the checks. One on another host is counted
like any client. A runner older than version 0.4 (April 2026) asks for work
every 2 seconds, 43,200 requests a day. While it runs a job, it reports the
job's log and state every second instead, two requests a second. So one runner
busy all day sends 120 requests a minute and 172,800 a day, under the request
limits.
Several runners on one address can pass them:
- Two busy all day make 345,600 requests, and pass the day limit of 300,000.
- Three busy at once make 21,600 an hour, and pass the hour limit of 20,000.
- Five busy at once make 600 a minute, and pass the minute limit of 500.
Their address is then banned, their running jobs fail, and each repeat within a
day makes the ban longer. Newer runners ask for work less often. Put the
runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
## What the Core Rule Set refuses in `block` mode
These notes record what the Core Rule Set does in front of gitea in `block`
mode, at the other defaults. They are observations, not requirements.
`script/gitea-requests` runs gitea behind `rproxy` and `servewaf` at the
defaults. It sends through them a sample of gitea's ordinary use and the
requests named below. It reports each request the Core Rule Set refuses, so that
these notes can be checked again when gitea or the Core Rule Set changes
version. In `detect` mode, as the setup runs it, the same requests are logged
with their rule ids, and pass.
Each refusal answers only that request ("Bans" in `SPEC.md` says what it counts
toward). The request log names the rule in `waf_rule_ids`, which
`SWWAF_WAF_DISABLED_RULES` can switch off.
### What it lets through
The Core Rule Set lets gitea's ordinary use through, apart from the refusals in
the sections after this one:
- browsing and views of files in a repository, with their history, blame and the - browsing and views of files in a repository, with their history, blame and the
file tree; file tree;
@@ -57,12 +193,6 @@ the refusals in the next section:
editor, code included, since no body is read; editor, code included, since no body is read;
- the page gitea shows after a change, with its message naming what was done. - the page gitea shows after a change, with its message naming what was done.
## What the Core Rule Set still refuses
Each refusal answers only that request ("Bans" in `SPEC.md` says what it counts
toward). The request log names the rule in `waf_rule_ids`, which
`SWWAF_WAF_DISABLED_RULES` can switch off.
### A query string that reads as an attack ### A query string that reads as an attack
Most often this is a search. A search term is a query parameter, which the Core Most often this is a search. A search term is a query parameter, which the Core
@@ -180,7 +310,7 @@ the runner. The error burst ("Attack detection" in `SPEC.md`) bans it after more
than 30 such refusals from its address in a minute, as when many jobs upload at than 30 such refusals from its address in a minute, as when many jobs upload at
once. `actions/upload-artifact@v4` does not send the header. once. `actions/upload-artifact@v4` does not send the header.
## What body inspection refuses ### What body inspection refuses
With `SWWAF_WAF_BODY_LIMIT` set, the Core Rule Set reads request bodies too. In With `SWWAF_WAF_BODY_LIMIT` set, the Core Rule Set reads request bodies too. In
front of gitea it then refuses these, until their rule ids, named in the request front of gitea it then refuses these, until their rule ids, named in the request
@@ -192,7 +322,7 @@ log, are added to `SWWAF_WAF_DISABLED_RULES`:
- PyPI uploads (922130); - PyPI uploads (922130);
- attachments named like `debug.log` or `config.yml` (932180). - attachments named like `debug.log` or `config.yml` (932180).
## What `script/gitea-requests` sends ### What `script/gitea-requests` sends
Of the requests above, the script sends: Of the requests above, the script sends:
+15 -18
View File
@@ -99,22 +99,19 @@ chooses between the two (see "The image's command" below).
## Request rate limits, per client ## Request rate limits, per client
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a - `SWWAF_RATE_LIMIT_PER_MINUTE` (default `500`, about 8 a second): the most
client may make in a minute. A count or `off`. The default leaves room for a requests a client may make in a minute. A count or `off`. The default catches,
person opening several gitea pages at once with an empty cache, 20 to 60 files within seconds, a client firing requests as fast as it can. It leaves room for
each. a fleet of about 20 agents at its busiest, about 120 requests a minute. It
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may also leaves room for a few quick first visits to gitea with an empty cache,
make in an hour. A count or `off`. It is the main limit against scrapers. A about 40 files each.
person reading code in gitea for an hour, a page every 20 seconds, makes - `SWWAF_RATE_LIMIT_PER_HOUR` (default `20000`, about 5.5 a second): the most
about 900. A scraper making one request every 2 seconds makes 1,800 an hour, requests a client may make in an hour. A count or `off`. The default leaves
more than the limit. room for an automation fleet at work. It catches a client that keeps up more
- `SWWAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may than about 5.5 a second for an hour.
make in a day. A count or `off`. The default is four hours at the hour limit. - `SWWAF_RATE_LIMIT_PER_DAY` (default `300000`, about 3.5 a second): the most
A scraper making one request every 10 seconds makes 8,640 a day, more than the requests a client may make in a day. A count or `off`. The default catches a
limit. scraper that keeps up 4 a second around the clock.
- An Actions runner asks gitea for work all day and can pass the limits. Put
their addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a network
where many people share one address, such as an office.
- Counting cannot catch a client that stays under all three limits (see "Request - Counting cannot catch a client that stays under all three limits (see "Request
rate limits, per client" in [`SPEC.md`](../SPEC.md)). rate limits, per client" in [`SPEC.md`](../SPEC.md)).
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from - `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
@@ -122,8 +119,8 @@ chooses between the two (see "The image's command" below).
## Byte limits, per client ## Byte limits, per client
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWWAF_BYTES_LIMIT_PER_HOUR` - `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `4G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
(default `50G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most (default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `200G`): the most
bytes counted for a client in each window. A size or `off`. bytes counted for a client in each window. A size or `off`.
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count: - `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
`response`, `request` or `both`. `response`, `request` or `both`.
+6 -6
View File
@@ -468,13 +468,13 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""), AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""), RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""), DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "120"), RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "500"),
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "1500"), RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "20000"),
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "6000"), RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "300000"),
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""), RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "off"), BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "4G"),
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "50G"), BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "20G"),
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "100G"), BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "200G"),
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"), BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"),
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"), LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""), LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
+11 -11
View File
@@ -244,9 +244,9 @@ func TestDefaults(t *testing.T) {
wantSettings(t, cfg, config.Config{ wantSettings(t, cfg, config.Config{
ListenAddr: ":8080", ListenAddr: ":8080",
Observe: false, Observe: false,
RateLimitPerMinute: 120, RateLimitPerMinute: 500,
RateLimitPerHour: 1500, RateLimitPerHour: 20000,
RateLimitPerDay: 6000, RateLimitPerDay: 300000,
BanResponse: 403, BanResponse: 403,
LimitBanDuration: time.Hour, LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour, LimitBanRepeatWindow: 24 * time.Hour,
@@ -266,8 +266,8 @@ func TestDefaults(t *testing.T) {
LookupSource: defaultLookupSource, LookupTimeout: 800 * time.Millisecond, LookupSource: defaultLookupSource, LookupTimeout: 800 * time.Millisecond,
}) })
wantByteLimitSettings(t, cfg, config.Config{ wantByteLimitSettings(t, cfg, config.Config{
BytesLimitPerMinute: 0, BytesLimitPerHour: 50 << 30, BytesLimitPerMinute: 4 << 30, BytesLimitPerHour: 20 << 30,
BytesLimitPerDay: 100 << 30, BytesCount: "both", BytesLimitPerDay: 200 << 30, BytesCount: "both",
}) })
wantNetblocks(t, cfg.AllowNets) wantNetblocks(t, cfg.AllowNets)
@@ -308,7 +308,7 @@ func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
for _, limit := range []int64{ for _, limit := range []int64{
cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay, cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay,
} { } {
if limit != 0 && largest > limit { // 0 is off if largest > limit {
t.Errorf("a request of %d bytes breaks the byte limit of %d", largest, limit) t.Errorf("a request of %d bytes breaks the byte limit of %d", largest, limit)
} }
} }
@@ -2271,12 +2271,12 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
rateLimitExemptNets: "", rateLimitExemptNets: "",
denyNets: "", denyNets: "",
rateLimitPerMinute: "45", rateLimitPerMinute: "45",
rateLimitPerHour: "1500", rateLimitPerHour: "20000",
rateLimitPerDay: "6000", rateLimitPerDay: "300000",
rateLimitExemptPaths: "", rateLimitExemptPaths: "",
bytesLimitPerMinute: "off", bytesLimitPerMinute: "4G",
bytesLimitPerHour: "50G", bytesLimitPerHour: "20G",
bytesLimitPerDay: "100G", bytesLimitPerDay: "200G",
bytesCount: "both", bytesCount: "both",
lookupSource: defaultLookupSource, lookupSource: defaultLookupSource,
lookupDBPath: "", lookupDBPath: "",
+3 -3
View File
@@ -1225,9 +1225,9 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
"SWWAF_ALLOW_NETS": "", "SWWAF_ALLOW_NETS": "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
"SWWAF_DENY_NETS": "", "SWWAF_DENY_NETS": "",
"SWWAF_RATE_LIMIT_PER_MINUTE": "120", "SWWAF_RATE_LIMIT_PER_MINUTE": "500",
"SWWAF_RATE_LIMIT_PER_HOUR": "1500", "SWWAF_RATE_LIMIT_PER_HOUR": "20000",
rateLimitPerDay: "6000", rateLimitPerDay: "300000",
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "", "SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
"SWWAF_DENIED_COUNTRIES": "", "SWWAF_DENIED_COUNTRIES": "",
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "", "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
+216
View File
@@ -0,0 +1,216 @@
#!/bin/sh
# script/gitea-setup: build the image, bring up deploy/gitea under a project
# name of its own, and check it:
# - that a page load, a push and a clone sent through traefik pass through
# rproxy, and that servewaf checks them;
# - that gitea logs the visitor's address, not rproxy's;
# - that a request from a container on the shared network skips the checks;
# - that with servewaf stopped, gitea still answers through rproxy.
# It publishes no port of traefik's, since this host's ports 80 and 443 may be
# taken. It reaches traefik at its address on the shared network instead, so
# its own requests come from the network's gateway, which servewaf checks like
# any visitor. Two runs at once on one docker host cannot both start, since
# both would use the subnet compose.yaml names. The containers, the network,
# the volumes, the image and the files the script makes are removed however it
# ends; the gitea and traefik images stay, as the base images of a build do.
# It needs network access. script/check does not run this.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
# Named after this run, so that runs in other clones on the same host
# never touch each other's.
NAME="$("$SCRIPT_DIR/projectname")-gitea-setup-$$"
IMAGE="$NAME-image"
NETWORK="${NAME}_shared"
VISITOR="$NAME-visitor"
# The site's host name and gitea's image, as deploy/gitea/compose.yaml names
# them. gitea's image also runs the container on the shared network.
SITE="git.example.org"
# gitea 1.27.3, 2026-08-29
GITEA_IMAGE="gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef"
# gitea's one user, who owns the repository.
PASSWORD="owner-password"
# The files the script makes, gitea's secret key for this run, and traefik's
# and the gateway's addresses on the shared network once the setup runs.
WORK=""
secret_key=""
traefik=""
gateway=""
cleanup() {
# So that a signal cannot stop it partway through.
trap '' HUP INT TERM
docker rm --force "$VISITOR" >/dev/null 2>&1 || true
if [ -n "$WORK" ]; then
compose down --volumes --remove-orphans --timeout 5 >/dev/null 2>&1 || true
rm -rf "$WORK"
fi
docker rmi --force "$IMAGE" >/dev/null 2>&1 || true
}
# compose <argument>...: docker compose on the setup, under this run's project
# name, with the image built here, this run's secret key and no port of
# traefik's published.
compose() {
SMALLWEBWAF_IMAGE="$IMAGE" GITEA_SECRET_KEY="$secret_key" \
docker compose --project-name "$NAME" \
--file "$ROOT/deploy/gitea/compose.yaml" --file "$WORK/no-ports.yaml" "$@"
}
fail() {
echo "gitea-setup: $*; the last lines of each container's output:" >&2
compose logs --no-color --tail 30 >&2 || true
exit 1
}
# wait_for <what fails> <command>...: run the command every second until
# it succeeds, for at most a minute.
wait_for() {
failure="$1"
shift
tries=0
until "$@"; do
tries=$((tries + 1))
[ "$tries" -lt 60 ] || fail "$failure"
sleep 1
done
}
# logged <service> <text>...: a line of the service's output holds every text,
# in any order.
logged() {
lines="$(compose logs --no-color --no-log-prefix "$1" 2>&1)"
shift
for text in "$@"; do
lines="$(printf '%s\n' "$lines" | grep -F -e "$text")" || return 1
done
}
# send <path> <curl option>...: send a request for path to the site, at
# traefik's address, and print the status of the answer, 000 for none. traefik
# answers with a certificate it made itself, since the setup names no
# certificate resolver.
send() {
path="$1"
shift
curl --silent --show-error --insecure --max-time 30 --output /dev/null \
--write-out '%{http_code}' --resolve "$SITE:443:$traefik" "$@" \
"https://$SITE$path" || true
}
# routed: traefik passes a request for the site's front page on to gitea.
routed() {
[ "$(send / 2>/dev/null)" = 200 ]
}
# site_git <git argument>...: run git as the owner, without the host's own git
# settings, reaching the site at traefik's address. git gives up on a transfer
# slower than 1000 bytes a second for 30 seconds.
site_git() {
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git \
-c user.name=owner -c user.email=owner@example.invalid \
-c http.sslVerify=false -c "http.curloptResolve=$SITE:443:$traefik" \
-c http.lowSpeedLimit=1000 -c http.lowSpeedTime=30 "$@"
}
# address_of <service>: print the service's address on the shared network.
address_of() {
docker inspect --format "{{(index .NetworkSettings.Networks \"$NETWORK\").IPAddress}}" \
"$(compose ps --quiet "$1")"
}
# passed_through <method> <path>: rproxy passed a request from the gateway for
# path to gitea on servewaf's allow, and servewaf checked it: its report line
# gives the client's counts and the Core Rule Set's score.
passed_through() {
wait_for "rproxy logged no $1 $2 that servewaf allowed" logged rproxy \
"\"method\":\"$1\"" "\"path\":\"$2\"" "\"client_ip\":\"$gateway\"" \
'"disposition":"allow"'
wait_for "servewaf logged no check of $1 $2" logged servewaf \
'"servewaf_line":"report"' "\"method\":\"$1\"" "\"path\":\"$2\"" \
"\"client_ip\":\"$gateway\"" '"action":"forward"' '"counts":' '"waf_score":'
}
main() {
cd "$ROOT"
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
WORK="$(mktemp -d)"
printf 'services:\n traefik:\n ports: !reset []\n' >"$WORK/no-ports.yaml"
docker build --no-cache -t "$IMAGE" .
secret_key="$(docker run --rm "$GITEA_IMAGE" gitea generate secret SECRET_KEY)"
# rproxy is healthy once gitea takes connections.
compose up --detach --quiet-pull --wait --wait-timeout 120 ||
fail "the setup did not start"
traefik="$(address_of traefik)"
gateway="$(docker network inspect --format '{{(index .IPAM.Config 0).Gateway}}' "$NETWORK")"
wait_for "traefik did not route the site to rproxy" routed
compose exec --user git gitea gitea admin user create --admin \
--username owner --password "$PASSWORD" --email owner@example.invalid \
--must-change-password=false >/dev/null || fail "creating gitea's user failed"
status="$(send /api/v1/user/repos --user "owner:$PASSWORD" \
--header "Content-Type: application/json" --data '{"name":"repo"}')"
[ "$status" = 201 ] || fail "creating the repository was answered $status"
status="$(send /owner/repo)"
[ "$status" = 200 ] || fail "the repository's page was answered $status"
passed_through GET /owner/repo
echo "gitea-setup: a page load passes through rproxy, and servewaf checks it"
wait_for "gitea did not log the visitor's address" logged gitea \
"GET /owner/repo for $gateway:"
echo "gitea-setup: gitea logs the visitor's address, not rproxy's"
site_git init --quiet --initial-branch=main "$WORK/repo"
echo "hello" >"$WORK/repo/README.md"
site_git -C "$WORK/repo" add README.md
site_git -C "$WORK/repo" commit --quiet --message "Add a README"
site_git -C "$WORK/repo" push --quiet \
"https://owner:$PASSWORD@$SITE/owner/repo.git" main ||
fail "the push failed"
passed_through POST /owner/repo.git/git-receive-pack
echo "gitea-setup: a push passes through rproxy, and servewaf checks it"
site_git clone --quiet "https://owner:$PASSWORD@$SITE/owner/repo.git" \
"$WORK/clone" || fail "the clone failed"
[ "$(cat "$WORK/clone/README.md")" = hello ] || fail "the clone lacks the README"
passed_through POST /owner/repo.git/git-upload-pack
echo "gitea-setup: a clone passes through rproxy, and servewaf checks it"
# A container on the shared network, as sandcastle is, asks for the site
# by its name, and so reaches traefik from its own address.
status="$(docker run --rm --name "$VISITOR" --network "$NETWORK" \
--entrypoint curl "$GITEA_IMAGE" --silent --insecure --max-time 30 \
--output /dev/null --write-out '%{http_code}' "https://$SITE/api/v1/version" ||
true)"
[ "$status" = 200 ] || fail "the request from the shared network was answered $status"
wait_for "servewaf logged no request from the shared network" logged servewaf \
'"servewaf_line":"report"' '"path":"/api/v1/version"' '"action":"forward"'
line="$(compose logs --no-color --no-log-prefix servewaf |
grep -F '"servewaf_line":"report"' | grep -F '"path":"/api/v1/version"')"
case "$line" in
*"\"client_ip\":\"$gateway\""*)
fail "the request from the shared network came from the gateway" ;;
*'"counts":'* | *'"waf_score":'*)
fail "servewaf checked the request from the shared network" ;;
esac
echo "gitea-setup: a request from the shared network skips the checks"
compose stop servewaf >/dev/null 2>&1 || fail "servewaf did not stop"
status="$(send /owner/repo)"
[ "$status" = 200 ] ||
fail "with servewaf stopped, the repository's page was answered $status"
wait_for "rproxy logged no request SWRP_DEFAULT_DISPOSITION decided" \
logged rproxy '"path":"/owner/repo"' '"default_disposition":true'
echo "gitea-setup: with servewaf stopped, gitea answers through rproxy"
}
main "$@"