Compare commits

...
Author SHA1 Message Date
sneak 37419716ea rproxy: circuit breaker for asks to waf (closes #392)
check / check (push) Failing after 6m19s
Five failed asks to waf within five seconds open the breaker. An ask
fails when it gets no answer in time, slow answers included, or an
answer rproxy cannot pass on. Every usable answer clears the failures
and closes the breaker. While open, a request takes
SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION without asking waf, except
one ask a second. SPEC.md describes it.

The sww test that stops and restarts waf now waits for the breaker to
close before it expects the ban again.

Model: opus-5-5
2026-11-15 15:30:29 +00:00
11 changed files with 473 additions and 28 deletions

No files matched your search

+2 -2
View File
@@ -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 keeps nothing between requests and writes nothing to disk. back. It 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
@@ -298,7 +298,7 @@ Every setting the code reads, with its default:
| `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. | | `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWW_RPROXY_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. | | `SWW_RPROXY_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. | | `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
| `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. | | `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` gives no answer in time, and while the circuit breaker for asks to `waf` is open: `allow` passes it to the app, `deny` answers `503`. |
| `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. | | `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWW_RPROXY_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. | | `SWW_RPROXY_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
| `SWW_RPROXY_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. | | `SWW_RPROXY_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
+38 -14
View File
@@ -2583,11 +2583,11 @@ 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 keeps nothing between requests and request, and passes on what `waf` allows. It writes nothing to disk. The only
writes nothing to disk. When `waf` cannot be connected to, does not answer at thing it keeps between requests is its circuit breaker for asks to `waf`.
all, or does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides what is done with a
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides. Its default, `allow`, request when `waf` gives no answer in time, and while the circuit breaker is
keeps the site answering. open. Its default, `allow`, keeps the site answering.
`rproxy` reads only its `SWW_RPROXY_` settings, which `docs/settings.md` lists. `rproxy` reads only its `SWW_RPROXY_` settings, which `docs/settings.md` lists.
One of them is required: `SWW_RPROXY_WAF_BACKEND`, `waf`'s URL. It has no One of them is required: `SWW_RPROXY_WAF_BACKEND`, `waf`'s URL. It has no
@@ -2605,7 +2605,8 @@ only. The image runs it by default (see "Deployment").
4. It refuses a request with a method it does not pass on, or one it cannot put 4. It refuses a request with a method it does not pass on, or one it cannot put
to `waf` as the visitor sent it (see "Requests `rproxy` refuses itself"). to `waf` as the visitor sent it (see "Requests `rproxy` refuses itself").
5. It reads up to the first 64 KiB of the body into memory. 5. It reads up to the first 64 KiB of the body into memory.
6. It sends `waf` the question (see "The question"). It speaks HTTP/1.1 to 6. It sends `waf` the question (see "The question"), unless the circuit breaker
keeps it from asking (see "The circuit breaker"). It speaks HTTP/1.1 to
`waf`, whatever `waf` offers. Go's HTTP/2 client would refuse to send a `waf`, whatever `waf` offers. Go's HTTP/2 client would refuse to send a
visitor's `Upgrade` header, and most `Connection` headers. The question never visitor's `Upgrade` header, and most `Connection` headers. The question never
carries the visitor's `Trailer`. carries the visitor's `Trailer`.
@@ -2679,23 +2680,24 @@ 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 only when `waf`: request 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. An answer whose body breaks off counts as no answer at all. The setting also
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
`waf_backend_no_answer`, and it sends no report. `waf_backend_no_answer`, and it sends no report.
A visitor can make `waf` slow. A request that keeps `waf` busy past `waf` taking longer than `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` to decide a request
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, like any request while `waf` is overloaded, is a bug in `waf`. Such a request gets
gets `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. With `deny`, such a request `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`, like any request while `waf` is
gets `503` instead of reaching the app. overloaded. With `deny`, it gets `503` instead of reaching the app.
A path under `/_sww/` that `allow` would pass on is answered `404` instead, so A path under `/_sww/` that `allow` would pass on is answered `404` instead, so
that the app never sees that prefix. that the app never sees that prefix.
@@ -2710,6 +2712,28 @@ 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` gives no answer in time (see "Without an answer from
`waf`"), or an answer `rproxy` cannot pass on. Any other answer in time
succeeds, whatever it decides. 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`.
- Each ask that succeeds clears the failures counted so far, and closes the
breaker if it is open.
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
@@ -2983,8 +3007,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` keeps nothing, and needs no app image built on it would inherit it. `rproxy` writes nothing to disk, and
volume. needs no 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
+2 -1
View File
@@ -238,7 +238,8 @@ meanings where they differ:
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It - `disposition`: what was done with the request: `allow`, `deny` or `close`. It
is `waf`'s answer, or the value of is `waf`'s answer, or the value of
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. It is left out when: `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. It is left out when:
- `waf` was not asked, as for the health check; - `waf` was not asked and `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` did
not apply, as for the health check;
- the visitor went away while `waf` was being asked; - the visitor went away while `waf` was being asked;
- `waf`'s answer had no valid `X-Sww-Disposition`; - `waf`'s answer had no valid `X-Sww-Disposition`;
- `rproxy` could not pass on `waf`'s answer, and answered `502`. - `rproxy` could not pass on `waf`'s answer, and answered `502`.
+3 -4
View File
@@ -453,10 +453,9 @@ start, and an invalid value stops the start. Any can be given as a file, as a
for `waf`'s answer, to a question and to a report. A time in milliseconds; for `waf`'s answer, to a question and to a report. A time in milliseconds;
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` gives no answer in time, and while the circuit
does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. `allow` passes it breaker for asks to `waf` is open. `allow` passes it to the app, and `deny`
to the app, and `deny` answers `503`. It does not apply to an answer `waf` answers `503`. It does not apply to an answer `waf` gives, such as an error.
gives, such as an 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
+82
View File
@@ -0,0 +1,82 @@
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, or an answer rproxy cannot pass on. While it is open, at
// most one request each breakerAskInterval asks waf. Any usable answer closes
// it, whatever it decides, and forgets the failures before it.
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 rproxy can use. It closes the
// breaker and forgets the failures before it.
func (b *breaker) noteAnswer() {
b.mu.Lock()
defer b.mu.Unlock()
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
}
}
+142
View File
@@ -0,0 +1,142 @@
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 TestAnAnswerBetweenFailedAsksKeepsTheBreakerClosed(t *testing.T) {
t.Parallel()
now := time.Now()
b := &rproxy.Breaker{}
fail(b, now, 4, 100*time.Millisecond)
b.NoteAnswer()
fail(b, now.Add(500*time.Millisecond), 4, 100*time.Millisecond)
if !b.MayAsk(now.Add(900 * time.Millisecond)) {
t.Error("a request may not ask, want it 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")
}
}
+23
View File
@@ -0,0 +1,23 @@
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 rproxy can use.
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)
}
+8 -5
View File
@@ -4,10 +4,11 @@
// 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. rproxy itself refuses a request with a // SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, or while the circuit breaker for asks to
// method it does not pass on, or one it cannot put to waf as the visitor // waf is open. rproxy itself refuses a request with a method it does not pass
// sent it. It keeps nothing between requests and writes nothing to disk. "rproxy" in // on, or one it cannot put to waf as the visitor sent it. It keeps nothing
// SPEC.md says what it does. // between requests but the circuit breaker, and writes nothing to disk.
// "rproxy" in SPEC.md says what it does.
package rproxy package rproxy
import ( import (
@@ -132,6 +133,8 @@ 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
@@ -196,7 +199,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
answer, err := rq.ask(r.Context()) answer, err := rq.askUnlessBreakerOpen(r.Context())
if err != nil { if err != nil {
rq.handleNoUsableAnswer(r.Context(), err) rq.handleNoUsableAnswer(r.Context(), err)
+134
View File
@@ -15,6 +15,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -1395,6 +1396,139 @@ 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 TestVisitorsThatGoAwayWhileWAFIsAskedLeaveTheBreakerClosed(
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
}
<-r.Context().Done()
})
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
wafBackendTimeout: "60000",
})
// Five of these would open the breaker if they counted as failures.
for range 5 {
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
id := standIn.question(t).header.Get(requestIDHeader)
_ = conn.Close()
// rproxy writes the request's log line once it is done with the ask.
out.waitFor(t, id)
}
answering.Store(true)
wantGreeting(t, get(t, addr, "/"))
standIn.question(t)
}
func TestAnswersThatCannotBePassedOnOpenTheBreaker(t *testing.T) {
t.Parallel()
var answering atomic.Bool
tooLarge := overOneMiB(dispositionDeny)
standIn := startWAF(t, func(w http.ResponseWriter, r *http.Request) {
if answering.Load() {
allow(w, r)
return
}
tooLarge(w, r)
})
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
wafBackendTimeout: "60000",
})
for range 5 {
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
t.Errorf("answered %d, want 502", got.status)
}
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()
+28 -1
View File
@@ -10,6 +10,7 @@ import (
"maps" "maps"
"net/http" "net/http"
"strings" "strings"
"time"
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/waf"
) )
@@ -67,7 +68,8 @@ 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. A request that keeps waf // within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS; or the circuit breaker
// 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
@@ -86,6 +88,31 @@ 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 counts as a failure. 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:
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.
+11 -1
View File
@@ -260,7 +260,17 @@ func TestTheSiteAnswersWhileWAFIsStoppedAndStartedAgain(t *testing.T) {
stop() stop()
}() }()
wantStatus(t, site+"/", placed, http.StatusForbidden) // The visitor's asks have opened rproxy's circuit breaker, which lets one
// 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