Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a93243a294 |
No files matched your search
+14
@@ -161,6 +161,20 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
|
|||||||
# ubuntu 26.04, 2026-09-27
|
# ubuntu 26.04, 2026-09-27
|
||||||
FROM ubuntu@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7
|
FROM ubuntu@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7
|
||||||
|
|
||||||
|
# The image's own labels, in place of the ones it would inherit from
|
||||||
|
# Ubuntu's. script/docker and script/cibuild pass the build arguments;
|
||||||
|
# a build that passes none labels the image unknown rather than leave
|
||||||
|
# Ubuntu's values.
|
||||||
|
ARG VERSION=unknown
|
||||||
|
ARG CREATED=unknown
|
||||||
|
ARG REVISION=unknown
|
||||||
|
LABEL org.opencontainers.image.title="smallwebwaf" \
|
||||||
|
org.opencontainers.image.description="A web application firewall for self-hosted web apps: sww rproxy in front of an app, and sww waf, which decides." \
|
||||||
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
|
org.opencontainers.image.created="${CREATED}" \
|
||||||
|
org.opencontainers.image.revision="${REVISION}" \
|
||||||
|
org.opencontainers.image.source="https://git.eeqj.de/sneak/smallwebwaf"
|
||||||
|
|
||||||
# runit's install creates its _runit-log user with minsysusers, which
|
# runit's install creates its _runit-log user with minsysusers, which
|
||||||
# reads this file in place of runit's /usr/lib/sysusers.d/runit.conf.
|
# reads this file in place of runit's /usr/lib/sysusers.d/runit.conf.
|
||||||
# runit's line leaves out the shell, and minsysusers prints a Perl
|
# runit's line leaves out the shell, and minsysusers prints a Perl
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ shipped in one image, with two commands that do this together:
|
|||||||
- `sww rproxy` is a small reverse proxy in front of one app. It sits behind a
|
- `sww rproxy` is a small reverse proxy in front of one app. It sits behind a
|
||||||
TLS-terminating proxy, such as traefik, nginx or Caddy. It asks `waf` about
|
TLS-terminating proxy, such as traefik, nginx or Caddy. It asks `waf` about
|
||||||
each request, passes the allowed ones to the app, and passes the app's answers
|
each request, passes the allowed ones to the app, and passes the app's answers
|
||||||
back. It writes nothing to disk.
|
back. It keeps nothing between requests and writes nothing to disk.
|
||||||
- `sww waf` decides. It runs the checks and keeps the bans, the counters and
|
- `sww waf` decides. It runs the checks and keeps the bans, the counters and
|
||||||
each client's history. One `waf` serves many sites: a client banned through
|
each client's history. One `waf` serves many sites: a client banned through
|
||||||
one site is refused on all of them, and its requests on all of them count
|
one site is refused on all of them, and its requests on all of them count
|
||||||
@@ -35,26 +35,7 @@ shipped in one image, with two commands that do this together:
|
|||||||
The site keeps answering while `waf` is down, slow or restarting: without an
|
The site keeps answering while `waf` is down, slow or restarting: without an
|
||||||
answer in time, `rproxy` passes the request to the app.
|
answer in time, `rproxy` passes the request to the app.
|
||||||
|
|
||||||
`rproxy` also protects the app on its own, whether or not `waf` answers. With
|
It protects against:
|
||||||
the default settings, `rproxy`:
|
|
||||||
|
|
||||||
- answers `400` to a malformed request, such as a broken header line or a
|
|
||||||
missing `Host`;
|
|
||||||
- answers `405` to a method it does not pass on, such as `TRACE`;
|
|
||||||
- answers `431` to a request line and headers over 32 KiB together;
|
|
||||||
- gives the visitor 60 seconds to send the request line and headers, and then 60
|
|
||||||
seconds more for the whole request to reach the app;
|
|
||||||
- answers `413` to a request body over 100 MiB;
|
|
||||||
- gives the app's answer 30 minutes to reach the visitor, counted from the end
|
|
||||||
of the request;
|
|
||||||
- answers `502` in place of an app's answer whose body is over 1 GiB, or cuts it
|
|
||||||
off if it has started;
|
|
||||||
- closes a kept-open connection after 120 seconds idle.
|
|
||||||
|
|
||||||
`rproxy` does not limit the number of headers. `waf` limits the number of
|
|
||||||
headers and cookies, and the length of the request line and of each header line.
|
|
||||||
|
|
||||||
Together, `rproxy` and `waf` protect 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 body bytes per minute, per hour and per day. The defaults
|
requests and on body bytes per minute, per hour and per day. The defaults
|
||||||
@@ -76,6 +57,8 @@ Together, `rproxy` and `waf` protect against:
|
|||||||
limit, so that they are banned sooner;
|
limit, so that they are banned sooner;
|
||||||
- refuse or limit clients on a blocklist, a DNS blocklist, or with a high
|
- refuse or limit clients on a blocklist, a DNS blocklist, or with a high
|
||||||
AbuseIPDB score.
|
AbuseIPDB score.
|
||||||
|
- Slow and oversized requests. Every request and every answer has a time limit
|
||||||
|
and a size limit.
|
||||||
|
|
||||||
For the operator, it:
|
For the operator, it:
|
||||||
|
|
||||||
|
|||||||
@@ -2583,10 +2583,9 @@ request and ban notes name the site by its host.
|
|||||||
## `rproxy`
|
## `rproxy`
|
||||||
|
|
||||||
`sww rproxy` is the reverse proxy in front of one app. It asks `waf` about each
|
`sww rproxy` is the reverse proxy in front of one app. It asks `waf` about each
|
||||||
request, and passes on what `waf` allows. It writes nothing to disk. The only
|
request, and passes on what `waf` allows. It keeps nothing between requests and
|
||||||
thing it keeps between requests is its circuit breaker for asks to `waf`. When
|
writes nothing to disk. When `waf` cannot be connected to, does not answer at
|
||||||
`waf` cannot be connected to, does not answer at all, or does not answer within
|
all, or does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`,
|
||||||
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`,
|
|
||||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides. Its default, `allow`,
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides. Its default, `allow`,
|
||||||
keeps the site answering.
|
keeps the site answering.
|
||||||
|
|
||||||
@@ -2680,15 +2679,14 @@ log line has status `0`.
|
|||||||
### Without an answer from `waf`
|
### Without an answer from `waf`
|
||||||
|
|
||||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides what is done with the
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides what is done with the
|
||||||
request when `waf`:
|
request only when `waf`:
|
||||||
|
|
||||||
- cannot be connected to;
|
- cannot be connected to;
|
||||||
- does not answer at all;
|
- does not answer at all;
|
||||||
- does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, which covers
|
- does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, which covers
|
||||||
connecting, sending the question and reading the whole answer.
|
connecting, sending the question and reading the whole answer.
|
||||||
|
|
||||||
An answer whose body breaks off counts as no answer at all. The setting also
|
An answer whose body breaks off counts as no answer at all.
|
||||||
decides, without asking `waf`, while the circuit breaker is open.
|
|
||||||
|
|
||||||
`allow`, the default, passes the request to the app. `deny` answers `503`.
|
`allow`, the default, passes the request to the app. `deny` answers `503`.
|
||||||
Either way `rproxy` writes a process warning, its log line has
|
Either way `rproxy` writes a process warning, its log line has
|
||||||
@@ -2712,31 +2710,6 @@ A visitor that goes away while `waf` is being asked is no failure of `waf`.
|
|||||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` does not apply. Its log line has
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` does not apply. Its log line has
|
||||||
status `0` and no `disposition`.
|
status `0` and no `disposition`.
|
||||||
|
|
||||||
### The circuit breaker
|
|
||||||
|
|
||||||
`rproxy` stops asking `waf` for a while when `waf` keeps failing to answer. An
|
|
||||||
ask fails when `waf` cannot be connected to, does not answer at all, or does not
|
|
||||||
answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. Any answer in time succeeds,
|
|
||||||
whatever it decides, and so does an answer `rproxy` cannot pass on. A visitor
|
|
||||||
that goes away while `waf` is being asked counts as neither.
|
|
||||||
|
|
||||||
The breaker works like this:
|
|
||||||
|
|
||||||
- It opens when 5 asks fail within 5 seconds. Fewer failures never open it.
|
|
||||||
- While it is open, a request gets
|
|
||||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` at once, without asking `waf`.
|
|
||||||
It gets the same process warning and `waf_backend_no_answer` as a request
|
|
||||||
whose ask failed.
|
|
||||||
- While it is open, one request a second still asks `waf`.
|
|
||||||
- The first ask that succeeds closes it, and the failures before it no longer
|
|
||||||
count.
|
|
||||||
|
|
||||||
A request that `waf` cannot decide within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`
|
|
||||||
counts as a failure too. Such a request is a bug in `waf`.
|
|
||||||
|
|
||||||
The breaker lives in memory, and a restart of `rproxy` closes it. It has no
|
|
||||||
settings.
|
|
||||||
|
|
||||||
### Passing a request to the app
|
### Passing a request to the app
|
||||||
|
|
||||||
`rproxy` sends the app at `SWW_RPROXY_UPSTREAM_URL` the visitor's request: the
|
`rproxy` sends the app at `SWW_RPROXY_UPSTREAM_URL` the visitor's request: the
|
||||||
@@ -3010,8 +2983,8 @@ 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
|
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
|
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
|
needs no backup beyond the host's own. The image declares no volume, since every
|
||||||
app image built on it would inherit it. `rproxy` writes nothing to disk, and
|
app image built on it would inherit it. `rproxy` keeps nothing, and needs no
|
||||||
needs no volume.
|
volume.
|
||||||
|
|
||||||
`waf` keeps its cache files in `SWW_WAF_CACHE_DIR`, `/var/cache/sww` by default,
|
`waf` keeps its cache files in `SWW_WAF_CACHE_DIR`, `/var/cache/sww` by default,
|
||||||
which the `run` script makes. The cache directory needs no volume and can be
|
which the `run` script makes. The cache directory needs no volume and can be
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
ARG SWW_IMAGE=smallwebwaf
|
ARG SWW_IMAGE=smallwebwaf
|
||||||
FROM ${SWW_IMAGE}
|
FROM ${SWW_IMAGE}
|
||||||
|
|
||||||
|
# Its own title and description, in place of the smallwebwaf image's.
|
||||||
|
LABEL org.opencontainers.image.title="smallwebwaf-example-app" \
|
||||||
|
org.opencontainers.image.description="An example app built on the smallwebwaf image."
|
||||||
|
|
||||||
# Packages the app needs, from the nixpkgs in the image.
|
# Packages the app needs, from the nixpkgs in the image.
|
||||||
RUN nix-env -iA nixpkgs.busybox
|
RUN nix-env -iA nixpkgs.busybox
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -454,10 +454,9 @@ start, and an invalid value stops the start. Any can be given as a file, as a
|
|||||||
never `off`.
|
never `off`.
|
||||||
- `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` (default `allow`): what is done
|
- `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` (default `allow`): what is done
|
||||||
with a request when `waf` cannot be connected to, does not answer at all, or
|
with a request when `waf` cannot be connected to, does not answer at all, or
|
||||||
does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, and while the
|
does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. `allow` passes it
|
||||||
circuit breaker for asks to `waf` is open. `allow` passes it to the app, and
|
to the app, and `deny` answers `503`. It does not apply to an answer `waf`
|
||||||
`deny` answers `503`. It does not apply to an answer `waf` gives, such as an
|
gives, such as an error.
|
||||||
error.
|
|
||||||
- `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A
|
- `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A
|
||||||
size or `off`.
|
size or `off`.
|
||||||
- `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
|
- `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
package rproxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What opens the circuit breaker, and how often a request asks waf while it is
|
|
||||||
// open.
|
|
||||||
const (
|
|
||||||
breakerFailures = 5
|
|
||||||
breakerWindow = 5 * time.Second
|
|
||||||
breakerAskInterval = time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// errBreakerOpen is why a request did not ask waf.
|
|
||||||
var errBreakerOpen = errors.New(
|
|
||||||
"waf is not asked while the circuit breaker is open",
|
|
||||||
)
|
|
||||||
|
|
||||||
// breaker is the circuit breaker for asks to waf. It opens when
|
|
||||||
// breakerFailures asks fail within breakerWindow. An ask fails when it gets no
|
|
||||||
// answer in time. While it is open, at most one request each breakerAskInterval
|
|
||||||
// asks waf. The first answer closes it, whatever it decides.
|
|
||||||
type breaker struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
// failures holds the times of the latest failed asks, oldest first, at most
|
|
||||||
// breakerFailures of them.
|
|
||||||
failures []time.Time
|
|
||||||
open bool
|
|
||||||
// lastAsk is when a request last asked waf while the breaker was open.
|
|
||||||
lastAsk time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// mayAsk reports whether a request may ask waf at now. While the breaker is
|
|
||||||
// open, it says yes once each breakerAskInterval.
|
|
||||||
func (b *breaker) mayAsk(now time.Time) bool {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
if !b.open {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if now.Sub(b.lastAsk) < breakerAskInterval {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
b.lastAsk = now
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// noteAnswer notes that an ask got an answer in time. It closes the breaker,
|
|
||||||
// and forgets the failures before it.
|
|
||||||
func (b *breaker) noteAnswer() {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
if b.open {
|
|
||||||
b.open = false
|
|
||||||
b.failures = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// noteFailure notes that an ask that ended at now got no answer in time, and
|
|
||||||
// opens the breaker if breakerFailures asks have failed within breakerWindow.
|
|
||||||
func (b *breaker) noteFailure(now time.Time) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
b.failures = append(b.failures, now)
|
|
||||||
if len(b.failures) > breakerFailures {
|
|
||||||
b.failures = b.failures[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
if !b.open && len(b.failures) == breakerFailures &&
|
|
||||||
now.Sub(b.failures[0]) <= breakerWindow {
|
|
||||||
b.open = true
|
|
||||||
b.lastAsk = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package rproxy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The breaker tests give the breaker each time themselves, counted from
|
|
||||||
// time.Now(), and never wait.
|
|
||||||
|
|
||||||
// fail notes count failed asks on b, one each step from at.
|
|
||||||
func fail(b *rproxy.Breaker, at time.Time, count int, step time.Duration) {
|
|
||||||
for i := range count {
|
|
||||||
b.NoteFailure(at.Add(time.Duration(i) * step))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// openBreaker returns a breaker opened at now by five failed asks, one a
|
|
||||||
// second.
|
|
||||||
func openBreaker(now time.Time) *rproxy.Breaker {
|
|
||||||
b := &rproxy.Breaker{}
|
|
||||||
fail(b, now.Add(-4*time.Second), 5, time.Second)
|
|
||||||
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFourFailedAsksWithinFiveSecondsLeaveTheBreakerClosed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
b := &rproxy.Breaker{}
|
|
||||||
fail(b, now, 4, time.Second)
|
|
||||||
|
|
||||||
if !b.MayAsk(now.Add(3 * time.Second)) {
|
|
||||||
t.Error("a request may not ask, want it to")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFiveFailedAsksWithinFiveSecondsOpenTheBreaker(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
b := openBreaker(now)
|
|
||||||
|
|
||||||
if b.MayAsk(now) {
|
|
||||||
t.Error("a request may ask, want it not to")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFiveFailedAsksOverMoreThanFiveSecondsLeaveTheBreakerClosed(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
b := &rproxy.Breaker{}
|
|
||||||
fail(b, now, 5, 1300*time.Millisecond)
|
|
||||||
|
|
||||||
if !b.MayAsk(now.Add(6 * time.Second)) {
|
|
||||||
t.Error("a request may not ask, want it to")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAnswerBeforeTheBreakerOpensDoesNotForgetFailures(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
b := &rproxy.Breaker{}
|
|
||||||
fail(b, now, 4, time.Millisecond)
|
|
||||||
b.NoteAnswer()
|
|
||||||
b.NoteFailure(now.Add(time.Second))
|
|
||||||
|
|
||||||
if b.MayAsk(now.Add(time.Second)) {
|
|
||||||
t.Error("a request may ask, want it not to")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnOpenBreakerLetsOneRequestAskEachSecond(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
b := openBreaker(now)
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
after time.Duration
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{999 * time.Millisecond, false},
|
|
||||||
{time.Second, true},
|
|
||||||
{time.Second, false},
|
|
||||||
{1500 * time.Millisecond, false},
|
|
||||||
{2 * time.Second, true},
|
|
||||||
} {
|
|
||||||
got := b.MayAsk(now.Add(tc.after))
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("may ask %v after it opened: %v, want %v",
|
|
||||||
tc.after, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAFailedAskKeepsTheBreakerOpen(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
opened := time.Now()
|
|
||||||
b := openBreaker(opened)
|
|
||||||
now := opened.Add(time.Second)
|
|
||||||
b.MayAsk(now)
|
|
||||||
b.NoteFailure(now)
|
|
||||||
|
|
||||||
if b.MayAsk(now.Add(999 * time.Millisecond)) {
|
|
||||||
t.Error("a request may ask, want it not to")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !b.MayAsk(now.Add(time.Second)) {
|
|
||||||
t.Error("a request may not ask a second later, want it to")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOneAnswerClosesTheBreaker(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
opened := time.Now()
|
|
||||||
b := openBreaker(opened)
|
|
||||||
now := opened.Add(time.Second)
|
|
||||||
b.MayAsk(now)
|
|
||||||
b.NoteAnswer()
|
|
||||||
|
|
||||||
if !b.MayAsk(now) {
|
|
||||||
t.Error("a request may not ask, want it to")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The failures before the answer no longer count, so one more does not open
|
|
||||||
// the breaker again.
|
|
||||||
b.NoteFailure(now)
|
|
||||||
|
|
||||||
if !b.MayAsk(now) {
|
|
||||||
t.Error("a request may not ask after one failure, want it to")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package rproxy
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// Breaker is the circuit breaker, for tests that give it the time.
|
|
||||||
type Breaker struct {
|
|
||||||
b breaker
|
|
||||||
}
|
|
||||||
|
|
||||||
// MayAsk reports whether a request may ask waf at now.
|
|
||||||
func (b *Breaker) MayAsk(now time.Time) bool {
|
|
||||||
return b.b.mayAsk(now)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoteAnswer notes that an ask got an answer in time.
|
|
||||||
func (b *Breaker) NoteAnswer() {
|
|
||||||
b.b.noteAnswer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoteFailure notes that an ask that ended at now got no answer in time.
|
|
||||||
func (b *Breaker) NoteFailure(now time.Time) {
|
|
||||||
b.b.noteFailure(now)
|
|
||||||
}
|
|
||||||
@@ -4,11 +4,10 @@
|
|||||||
// waf goes to the visitor as waf gave it.
|
// waf goes to the visitor as waf gave it.
|
||||||
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION decides only when waf cannot
|
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION decides only when waf cannot
|
||||||
// be connected to, does not answer at all, or does not answer within
|
// be connected to, does not answer at all, or does not answer within
|
||||||
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, or while the circuit breaker for asks to
|
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. rproxy itself refuses a request with a
|
||||||
// waf is open. rproxy itself refuses a request with a method it does not pass
|
// method it does not pass on, or one it cannot put to waf as the visitor
|
||||||
// on, or one it cannot put to waf as the visitor sent it. It keeps nothing
|
// sent it. It keeps nothing between requests and writes nothing to disk. "rproxy" in
|
||||||
// between requests but the circuit breaker, and writes nothing to disk.
|
// SPEC.md says what it does.
|
||||||
// "rproxy" in SPEC.md says what it does.
|
|
||||||
package rproxy
|
package rproxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -133,8 +132,6 @@ type handler struct {
|
|||||||
wafTransport http.RoundTripper
|
wafTransport http.RoundTripper
|
||||||
// reportURL is where the reports go.
|
// reportURL is where the reports go.
|
||||||
reportURL string
|
reportURL string
|
||||||
// breaker is the circuit breaker for asks to waf.
|
|
||||||
breaker breaker
|
|
||||||
// reports waits for the reports being sent. reportsMu guards starting one
|
// reports waits for the reports being sent. reportsMu guards starting one
|
||||||
// against reportsStopped, which is set once nothing is to wait for another.
|
// against reportsStopped, which is set once nothing is to wait for another.
|
||||||
reports sync.WaitGroup
|
reports sync.WaitGroup
|
||||||
@@ -199,7 +196,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
answer, err := rq.askUnlessBreakerOpen(r.Context())
|
answer, err := rq.ask(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rq.handleNoUsableAnswer(r.Context(), err)
|
rq.handleNoUsableAnswer(r.Context(), err)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -1396,62 +1395,6 @@ func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
|||||||
standIn.wantNoReport(t)
|
standIn.wantNoReport(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFiveFailedAsksKeepTheNextRequestFromAskingWAF(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for name, failure := range map[string]http.HandlerFunc{
|
|
||||||
"no answer at all": func(http.ResponseWriter, *http.Request) {
|
|
||||||
panic(http.ErrAbortHandler)
|
|
||||||
},
|
|
||||||
"a slow answer": func(_ http.ResponseWriter, r *http.Request) {
|
|
||||||
<-r.Context().Done()
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var answering atomic.Bool
|
|
||||||
|
|
||||||
standIn := startWAF(
|
|
||||||
t,
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if answering.Load() {
|
|
||||||
allow(w, r)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
failure(w, r)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
app := startApp(t, greet)
|
|
||||||
addr, out := startRProxy(
|
|
||||||
t,
|
|
||||||
standIn.URL,
|
|
||||||
app.URL,
|
|
||||||
map[string]string{
|
|
||||||
wafBackendTimeout: "200",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
for range 5 {
|
|
||||||
wantGreeting(t, get(t, addr, "/"))
|
|
||||||
standIn.question(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
answering.Store(true)
|
|
||||||
wantGreeting(t, get(t, addr, "/"))
|
|
||||||
out.waitFor(t, "the circuit breaker is open")
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-standIn.questions:
|
|
||||||
t.Error("waf was asked, want it not to be")
|
|
||||||
case <-time.After(10 * pollInterval):
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+1
-28
@@ -10,7 +10,6 @@ import (
|
|||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
@@ -68,8 +67,7 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
|||||||
rq.answer(http.StatusBadGateway)
|
rq.answer(http.StatusBadGateway)
|
||||||
default:
|
default:
|
||||||
// waf cannot be connected to, does not answer at all, or does not answer
|
// waf cannot be connected to, does not answer at all, or does not answer
|
||||||
// within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS; or the circuit breaker
|
// within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. A request that keeps waf
|
||||||
// kept the request from asking. A request that keeps waf
|
|
||||||
// busy past SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, like any request while
|
// busy past SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, like any request while
|
||||||
// waf is overloaded, gets SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION
|
// waf is overloaded, gets SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION
|
||||||
// here. deny closes this, at the cost of answering 503 whenever waf is
|
// here. deny closes this, at the cost of answering 503 whenever waf is
|
||||||
@@ -88,31 +86,6 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// askUnlessBreakerOpen asks waf, unless the circuit breaker is open and it is
|
|
||||||
// not yet time for a request to ask. It then tells the breaker whether waf
|
|
||||||
// answered in time. An answer rproxy cannot pass on still came in time. A
|
|
||||||
// visitor that goes away tells the breaker nothing. ctx is the request's own
|
|
||||||
// context.
|
|
||||||
func (rq *request) askUnlessBreakerOpen(
|
|
||||||
ctx context.Context,
|
|
||||||
) (*wafAnswer, error) {
|
|
||||||
b := &rq.h.breaker
|
|
||||||
if !b.mayAsk(time.Now()) {
|
|
||||||
return nil, errBreakerOpen
|
|
||||||
}
|
|
||||||
|
|
||||||
answer, err := rq.ask(ctx)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case err == nil, errors.Is(err, errCannotPassOn):
|
|
||||||
b.noteAnswer()
|
|
||||||
case ctx.Err() == nil:
|
|
||||||
b.noteFailure(time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
return answer, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ask sends waf the question, and returns its answer, which must have come
|
// ask sends waf the question, and returns its answer, which must have come
|
||||||
// whole within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It ends when ctx does, as
|
// whole within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It ends when ctx does, as
|
||||||
// when the visitor goes away.
|
// when the visitor goes away.
|
||||||
|
|||||||
@@ -260,17 +260,7 @@ func TestTheSiteAnswersWhileWAFIsStoppedAndStartedAgain(t *testing.T) {
|
|||||||
stop()
|
stop()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// The visitor's asks have opened rproxy's circuit breaker, which lets one
|
wantStatus(t, site+"/", placed, http.StatusForbidden)
|
||||||
// request a second ask until one is answered.
|
|
||||||
deadline := time.Now().Add(waitLimit)
|
|
||||||
for statusFrom(t, site+"/", placed) != http.StatusForbidden {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("request from %s: never answered %d", placed,
|
|
||||||
http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(visitorStop)
|
close(visitorStop)
|
||||||
<-visitorDone
|
<-visitorDone
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ main() {
|
|||||||
# the version a build stage derives from the .git in the context.
|
# the version a build stage derives from the .git in the context.
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
[ -n "$version" ] || version="unknown"
|
[ -n "$version" ] || version="unknown"
|
||||||
|
revision="$(git rev-parse HEAD 2>/dev/null || true)"
|
||||||
|
[ -n "$revision" ] || revision="unknown"
|
||||||
|
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
docker build --no-cache \
|
docker build --no-cache \
|
||||||
--build-arg VERSION="$version" \
|
--build-arg VERSION="$version" \
|
||||||
|
--build-arg REVISION="$revision" \
|
||||||
|
--build-arg CREATED="$created" \
|
||||||
-t "$("$SCRIPT_DIR/projectname")" .
|
-t "$("$SCRIPT_DIR/projectname")" .
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ main() {
|
|||||||
# the version a build stage derives from the .git in the context.
|
# the version a build stage derives from the .git in the context.
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
[ -n "$version" ] || version="unknown"
|
[ -n "$version" ] || version="unknown"
|
||||||
|
revision="$(git rev-parse HEAD 2>/dev/null || true)"
|
||||||
|
[ -n "$revision" ] || revision="unknown"
|
||||||
|
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
docker build --no-cache \
|
docker build --no-cache \
|
||||||
--build-arg VERSION="$version" \
|
--build-arg VERSION="$version" \
|
||||||
|
--build-arg REVISION="$revision" \
|
||||||
|
--build-arg CREATED="$created" \
|
||||||
-t "$("$SCRIPT_DIR/projectname")" .
|
-t "$("$SCRIPT_DIR/projectname")" .
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user