Compare commits
4
Commits
95cfbdc03b
...
b0fa64804c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0fa64804c | ||
|
|
47aac0235d | ||
|
|
6327f7f9c5 | ||
|
|
157cdc23f3 |
+6
-1
@@ -140,10 +140,14 @@ RUN mkdir /etc/sysusers.d \
|
|||||||
# apt also fetches the live archive's InRelease files, which change daily
|
# apt also fetches the live archive's InRelease files, which change daily
|
||||||
# and which the install does not use. The snapshot service is HTTPS only
|
# and which the install does not use. The snapshot service is HTTPS only
|
||||||
# and this image has no CA certificates yet, so this step uses the Go
|
# and this image has no CA certificates yet, so this step uses the Go
|
||||||
# image's.
|
# image's. The snapshot service answers 503 at times; apt retries a
|
||||||
|
# failed download, waiting twice as long before each retry, and this
|
||||||
|
# step gives it five retries, about half a minute in all, where its
|
||||||
|
# default of three gives up after seven seconds.
|
||||||
RUN --mount=type=bind,from=builder,source=/etc/ssl/certs/ca-certificates.crt,target=/tmp/go-image-ca.crt \
|
RUN --mount=type=bind,from=builder,source=/etc/ssl/certs/ca-certificates.crt,target=/tmp/go-image-ca.crt \
|
||||||
apt-get update --snapshot 20261001T000000Z \
|
apt-get update --snapshot 20261001T000000Z \
|
||||||
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
||||||
|
-o Acquire::Retries=5 \
|
||||||
&& printf '%s\n' \
|
&& printf '%s\n' \
|
||||||
'45f95ce276cdba3e41870516a130e03c58b8b7a79e9546b0efe9e526d255740c snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute_InRelease' \
|
'45f95ce276cdba3e41870516a130e03c58b8b7a79e9546b0efe9e526d255740c snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute_InRelease' \
|
||||||
'802e675dd9de4c7f3916434a95e7c1d8eec0e82886622d7805ab19a2c6fe0365 snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-updates_InRelease' \
|
'802e675dd9de4c7f3916434a95e7c1d8eec0e82886622d7805ab19a2c6fe0365 snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-updates_InRelease' \
|
||||||
@@ -153,6 +157,7 @@ RUN --mount=type=bind,from=builder,source=/etc/ssl/certs/ca-certificates.crt,tar
|
|||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||||
--snapshot 20261001T000000Z \
|
--snapshot 20261001T000000Z \
|
||||||
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
||||||
|
-o Acquire::Retries=5 \
|
||||||
ca-certificates nix-bin runit \
|
ca-certificates nix-bin runit \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,59 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
|||||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 0, third: 1})
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 0, third: 1})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rules := defaultRules()
|
||||||
|
rules.MaxBans = 2
|
||||||
|
ledger := bans.New(rules)
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
||||||
|
other := netip.MustParsePrefix("203.0.113.2/32")
|
||||||
|
|
||||||
|
// An admin bans the netblock for a minute, and it then breaks a limit
|
||||||
|
// twice, each time after its last ban has ended.
|
||||||
|
ledger.BanForAdmin(netblock, midnight(), midnight().Add(time.Minute),
|
||||||
|
"probes for logins")
|
||||||
|
first, _ := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||||
|
second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||||
|
|
||||||
|
// A ban on another netblock fills the ledger, and the netblock's first
|
||||||
|
// ban for a limit, its earliest that is not an admin's, goes.
|
||||||
|
ledger.BanForLimit(other, second.Start, bans.Notes{})
|
||||||
|
wantBans(t, ledger, map[netip.Prefix]int{netblock: 2, other: 1})
|
||||||
|
|
||||||
|
// The netblock's next ban still counts it.
|
||||||
|
third, _ := ledger.BanForLimit(netblock, second.Expires, bans.Notes{})
|
||||||
|
if third.Notes.EarlierBans != (bans.EarlierBans{Limit: 2, Admin: 1}) {
|
||||||
|
t.Errorf("the third ban for a limit has earlier bans %+v, "+
|
||||||
|
"want 2 for a limit and 1 an admin's", third.Notes.EarlierBans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
||||||
|
|
||||||
|
// The netblock breaks a limit twice, the second time after its first
|
||||||
|
// ban has ended, and the second ban's notes count the first. An admin
|
||||||
|
// then keeps the first ban.
|
||||||
|
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
|
second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||||
|
|
||||||
|
kept := first
|
||||||
|
kept.Cause = bans.CauseAdmin
|
||||||
|
ledger.LoadEdit([]bans.Ban{kept, second})
|
||||||
|
|
||||||
|
// The netblock's next ban counts the kept ban once, as an admin's.
|
||||||
|
third, _ := ledger.BanForLimit(netblock, second.Expires, bans.Notes{})
|
||||||
|
if third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Admin: 1}) {
|
||||||
|
t.Errorf("the third ban for a limit has earlier bans %+v, "+
|
||||||
|
"want 1 for a limit and 1 an admin's", third.Notes.EarlierBans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+35
-10
@@ -687,26 +687,51 @@ func (l *Ledger) ban(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// earlierBans returns how many bans a netblock with the bans held, oldest
|
// earlierBans returns how many bans a netblock with the bans held, oldest
|
||||||
// first, has had, by cause: the first ban held counts the bans the
|
// first, has had, by cause. Each ban held gives a count: the bans its
|
||||||
// netblock had before that one, since dropped to make room, and each ban
|
// notes count before it, and one for it and each ban held after it. A
|
||||||
// held adds one.
|
// ban's count misses a ban dropped after it to make room, and that of an
|
||||||
|
// admin's ban added to bans.json without notes misses the bans before it,
|
||||||
|
// so the count that names the most bans is taken, whole: a ban an admin
|
||||||
|
// keeps by giving it CauseAdmin is still counted under its old cause in
|
||||||
|
// later bans' notes, and taking each cause from another count would count
|
||||||
|
// it twice.
|
||||||
func earlierBans(held []Ban) EarlierBans {
|
func earlierBans(held []Ban) EarlierBans {
|
||||||
earlier := held[0].Notes.EarlierBans
|
// fromHere counts ban and the bans held after it.
|
||||||
|
var most, fromHere EarlierBans
|
||||||
|
|
||||||
for _, ban := range held {
|
for _, ban := range slices.Backward(held) {
|
||||||
switch ban.Cause {
|
switch ban.Cause {
|
||||||
case CauseLimit:
|
case CauseLimit:
|
||||||
earlier.Limit++
|
fromHere.Limit++
|
||||||
case CauseAttack:
|
case CauseAttack:
|
||||||
earlier.Attack++
|
fromHere.Attack++
|
||||||
case CauseAdmin:
|
case CauseAdmin:
|
||||||
earlier.Admin++
|
fromHere.Admin++
|
||||||
case CauseCrowdSec:
|
case CauseCrowdSec:
|
||||||
earlier.CrowdSec++
|
fromHere.CrowdSec++
|
||||||
|
}
|
||||||
|
|
||||||
|
notes := ban.Notes.EarlierBans
|
||||||
|
count := EarlierBans{
|
||||||
|
Limit: notes.Limit + fromHere.Limit,
|
||||||
|
Attack: notes.Attack + fromHere.Attack,
|
||||||
|
Admin: notes.Admin + fromHere.Admin,
|
||||||
|
CrowdSec: notes.CrowdSec + fromHere.CrowdSec,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of counts that name as many bans, the earlier ban's is taken: it
|
||||||
|
// counts more of the bans held by the cause they have now.
|
||||||
|
if count.total() >= most.total() {
|
||||||
|
most = count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return earlier
|
return most
|
||||||
|
}
|
||||||
|
|
||||||
|
// total returns how many bans e counts, of all causes.
|
||||||
|
func (e EarlierBans) total() int {
|
||||||
|
return e.Limit + e.Attack + e.Admin + e.CrowdSec
|
||||||
}
|
}
|
||||||
|
|
||||||
// markChanged has Changed receive a value, unless one is waiting already.
|
// markChanged has Changed receive a value, unless one is waiting already.
|
||||||
|
|||||||
@@ -795,9 +795,19 @@ func (rq *request) responseTimedOut() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// responseReceived is called once the app has sent its whole answer.
|
// responseReceived is called once the app has sent its whole answer. An
|
||||||
|
// app may answer before the client has sent its whole body, as with a 404
|
||||||
|
// it sends without reading it. The timeouts then run on: until the
|
||||||
|
// transport's read of the body returns, Go's server cannot send the
|
||||||
|
// answer, and SWWAF_CLIENT_REQUEST_TIMEOUT is what ends that read for a
|
||||||
|
// client that stops sending.
|
||||||
func (rq *request) responseReceived() {
|
func (rq *request) responseReceived() {
|
||||||
rq.complete = true
|
rq.complete = true
|
||||||
|
|
||||||
|
if rq.body != nil && !rq.body.received.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
rq.stopTimers()
|
rq.stopTimers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,60 @@ func sendLargeBody(t *testing.T, addr string) net.Conn {
|
|||||||
return conn
|
return conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientStopsSendingAfterTheAppHasAnswered(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// answeredAt is when the app answered, or nil if the bytes the client
|
||||||
|
// sent never reached it.
|
||||||
|
var answeredAt atomic.Pointer[time.Time]
|
||||||
|
|
||||||
|
// The app answers without reading the whole body, as an app does for
|
||||||
|
// a path it does not serve, and only once smallwebwaf waits for the
|
||||||
|
// rest of it: the app takes the bytes the client sent, and then waits
|
||||||
|
// half the timeout, far longer than smallwebwaf takes to go on to
|
||||||
|
// wait.
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, err := io.ReadFull(r.Body, make([]byte, len("the first bytes")))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(shortTimeout / 2)
|
||||||
|
answeredAt.Store(new(time.Now()))
|
||||||
|
http.NotFound(w, r)
|
||||||
|
})
|
||||||
|
addr, out := startProxy(t, app.URL, map[string]string{
|
||||||
|
clientRequestTimeout: shortTimeoutSetting,
|
||||||
|
metricsToken: token,
|
||||||
|
})
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
got := readResponse(t, sendPartOfBody(t, addr))
|
||||||
|
wantTimedOut(t, start)
|
||||||
|
|
||||||
|
// Close returns once the app has finished with the request.
|
||||||
|
app.Close()
|
||||||
|
|
||||||
|
// The app's answer came before the time ran out, so the client is
|
||||||
|
// sent that answer, not smallwebwaf's 408. Only a hold-up of the test
|
||||||
|
// process can make it come later, and SPEC.md then asks for 408, as
|
||||||
|
// smallwebwaf was waiting for the client, or for 504 if the bytes the
|
||||||
|
// client sent had not reached the app.
|
||||||
|
at := answeredAt.Load()
|
||||||
|
want := http.StatusNotFound
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case at == nil:
|
||||||
|
want = http.StatusGatewayTimeout
|
||||||
|
case at.Sub(start) >= shortTimeout:
|
||||||
|
want = http.StatusRequestTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
wantStatus(t, got, want)
|
||||||
|
wantLine(t, out.requestLine(t), want, requestlog.ActionTimedOut)
|
||||||
|
wantLimitHits(t, addr, clientRequestTimeout, 1)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppTooSlowToAnswer(t *testing.T) {
|
func TestAppTooSlowToAnswer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+49
-6
@@ -5,12 +5,15 @@
|
|||||||
# served through smallwebwaf, that a second one in a minute bans the
|
# served through smallwebwaf, that a second one in a minute bans the
|
||||||
# client, that a probe for /.env bans another client, which its next
|
# client, that a probe for /.env bans another client, which its next
|
||||||
# request bans for good, that `sv stop` stops smallwebwaf in order, that
|
# request bans for good, that `sv stop` stops smallwebwaf in order, that
|
||||||
# `docker stop` stops the container without having to kill it, and that
|
# `docker stop` stops the container without having to kill it, that a
|
||||||
# a new container on the same volume still refuses the banned client. The
|
# new container on the same volume still refuses the banned client, that
|
||||||
|
# a container with its state files on a new volume mounted at the
|
||||||
|
# directory SWWAF_STATE_DIR_FILE names becomes healthy, and that one whose
|
||||||
|
# SWWAF_STATE_DIR does not exist logs smallwebwaf's message about it. The
|
||||||
# containers run with SWWAF_LOOKUP_SOURCE=off, so that no address is sent
|
# containers run with SWWAF_LOOKUP_SOURCE=off, so that no address is sent
|
||||||
# to GeoJS. The containers, the volume and both images are removed however
|
# to GeoJS. The containers, the volumes and both images are removed
|
||||||
# the script ends. Building the app needs network access, for nixpkgs'
|
# however the script ends. Building the app needs network access, for
|
||||||
# binary cache. script/check does not run this.
|
# nixpkgs' binary cache. script/check does not run this.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -23,10 +26,15 @@ IMAGE="$NAME-base"
|
|||||||
APP_IMAGE="$NAME-app"
|
APP_IMAGE="$NAME-app"
|
||||||
CONTAINER="$NAME"
|
CONTAINER="$NAME"
|
||||||
VOLUME="$NAME-state"
|
VOLUME="$NAME-state"
|
||||||
|
# The last container's: its state files, away from the default path, and
|
||||||
|
# the file that names their directory.
|
||||||
|
MOVED_VOLUME="$NAME-moved-state"
|
||||||
|
SETTINGS_VOLUME="$NAME-settings"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
docker rm --force "$CONTAINER" >/dev/null 2>&1 || true
|
docker rm --force "$CONTAINER" >/dev/null 2>&1 || true
|
||||||
docker volume rm --force "$VOLUME" >/dev/null 2>&1 || true
|
docker volume rm --force "$VOLUME" "$MOVED_VOLUME" "$SETTINGS_VOLUME" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
docker rmi --force "$APP_IMAGE" "$IMAGE" >/dev/null 2>&1 || true
|
docker rmi --force "$APP_IMAGE" "$IMAGE" >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +84,27 @@ start_container() {
|
|||||||
address="$(docker port "$CONTAINER" 8080/tcp)"
|
address="$(docker port "$CONTAINER" 8080/tcp)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# start_moved_container: run the app's container with the state files on
|
||||||
|
# a new volume at /srv/state, which SWWAF_STATE_DIR_FILE names in a file
|
||||||
|
# only the smallwebwaf user can read, as under "Settings given as files"
|
||||||
|
# in README.md, and wait until it is healthy. smallwebwaf can write the
|
||||||
|
# new volume only once the run script has given it to that user.
|
||||||
|
start_moved_container() {
|
||||||
|
docker volume create "$MOVED_VOLUME" >/dev/null
|
||||||
|
docker volume create "$SETTINGS_VOLUME" >/dev/null
|
||||||
|
docker run --rm --volume "$SETTINGS_VOLUME:/settings" \
|
||||||
|
--entrypoint bash "$IMAGE" -c 'echo /srv/state > /settings/state-dir &&
|
||||||
|
chown smallwebwaf:smallwebwaf /settings/state-dir &&
|
||||||
|
chmod 0400 /settings/state-dir'
|
||||||
|
docker run --detach --name "$CONTAINER" \
|
||||||
|
--volume "$MOVED_VOLUME:/srv/state" \
|
||||||
|
--volume "$SETTINGS_VOLUME:/etc/smallwebwaf/settings:ro" \
|
||||||
|
--env SWWAF_STATE_DIR_FILE=/etc/smallwebwaf/settings/state-dir \
|
||||||
|
--env SWWAF_LOOKUP_SOURCE=off \
|
||||||
|
"$APP_IMAGE" >/dev/null
|
||||||
|
wait_for "the health check did not pass" healthy
|
||||||
|
}
|
||||||
|
|
||||||
# refused: a request to the container gets 403, SWWAF_BAN_RESPONSE's
|
# refused: a request to the container gets 403, SWWAF_BAN_RESPONSE's
|
||||||
# default.
|
# default.
|
||||||
refused() {
|
refused() {
|
||||||
@@ -140,6 +169,20 @@ main() {
|
|||||||
wait_for "smallwebwaf logged no request refused under the ban" \
|
wait_for "smallwebwaf logged no request refused under the ban" \
|
||||||
logged '"action":"banned"'
|
logged '"action":"banned"'
|
||||||
echo "example-app: a new container on the same volume keeps the ban"
|
echo "example-app: a new container on the same volume keeps the ban"
|
||||||
|
|
||||||
|
docker rm --force "$CONTAINER" >/dev/null
|
||||||
|
start_moved_container
|
||||||
|
echo "example-app: the run script gives the state files' directory named" \
|
||||||
|
"by SWWAF_STATE_DIR_FILE to the smallwebwaf user"
|
||||||
|
|
||||||
|
docker rm --force "$CONTAINER" >/dev/null
|
||||||
|
docker run --detach --name "$CONTAINER" \
|
||||||
|
--env SWWAF_STATE_DIR=/srv/missing \
|
||||||
|
--env SWWAF_LOOKUP_SOURCE=off \
|
||||||
|
"$APP_IMAGE" >/dev/null
|
||||||
|
wait_for "smallwebwaf did not report the missing state directory" \
|
||||||
|
logged "SWWAF_STATE_DIR cannot be written"
|
||||||
|
echo "example-app: smallwebwaf reports a state directory that does not exist"
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+21
-3
@@ -5,12 +5,30 @@ set -euo pipefail
|
|||||||
# exits; the wait spaces out the restarts. The state directory and every
|
# exits; the wait spaces out the restarts. The state directory and every
|
||||||
# file in it are given to the smallwebwaf user, so that a volume mounted
|
# file in it are given to the smallwebwaf user, so that a volume mounted
|
||||||
# there needs no change of owner; chown -R changes a symbolic link itself,
|
# there needs no change of owner; chown -R changes a symbolic link itself,
|
||||||
# never what it points to. exec, so that the signal `sv stop` sends
|
# never what it points to. When SWWAF_STATE_DIR, in either form, cannot be
|
||||||
# reaches smallwebwaf itself.
|
# read or is not the absolute path of a directory, no owner is changed,
|
||||||
|
# and smallwebwaf starts and stops with a message naming the setting.
|
||||||
|
# exec, so that the signal `sv stop` sends reaches smallwebwaf itself.
|
||||||
main() {
|
main() {
|
||||||
sleep 1
|
sleep 1
|
||||||
chown -R smallwebwaf:smallwebwaf "${SWWAF_STATE_DIR:-/var/lib/smallwebwaf}"
|
local dir
|
||||||
|
if dir="$(state_dir)" && [[ "$dir" == /* && -d "$dir" ]]; then
|
||||||
|
chown -R smallwebwaf:smallwebwaf "$dir"
|
||||||
|
fi
|
||||||
exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf
|
exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# state_dir prints SWWAF_STATE_DIR, /var/lib/smallwebwaf when it is unset,
|
||||||
|
# or what is in the file SWWAF_STATE_DIR_FILE names, less every newline at
|
||||||
|
# its end. It fails when both are set or the file cannot be read.
|
||||||
|
state_dir() {
|
||||||
|
if [[ ! -v SWWAF_STATE_DIR_FILE ]]; then
|
||||||
|
printf '%s\n' "${SWWAF_STATE_DIR-/var/lib/smallwebwaf}"
|
||||||
|
elif [[ ! -v SWWAF_STATE_DIR ]]; then
|
||||||
|
cat -- "$SWWAF_STATE_DIR_FILE" 2>/dev/null
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user