check / check (push) Failing after 3m27s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Every place a name appeared changed: the code, the viper keys, tests, documents, examples, the README's settings table and the image files. Model: deepseek-v4-pro
1774 lines
46 KiB
Go
1774 lines
46 KiB
Go
package rproxy_test
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/viper"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
|
)
|
|
|
|
const (
|
|
// waitLimit bounds how long a test waits for what should happen.
|
|
waitLimit = 10 * time.Second
|
|
// pollInterval is how often a test looks for a log line.
|
|
pollInterval = 10 * time.Millisecond
|
|
// localhost is where every test server listens, and so the address rproxy
|
|
// sees each test's requests come from.
|
|
localhost = "127.0.0.1"
|
|
// site is the host the tests' requests ask for.
|
|
site = "site.example"
|
|
// greeting is what the tests' app answers by default.
|
|
greeting = "hello from the app"
|
|
// nowhere is a URL nothing listens at: port 9 is the discard service, which
|
|
// nothing on a test host runs.
|
|
nowhere = "http://127.0.0.1:9"
|
|
)
|
|
|
|
// The settings the tests set.
|
|
const (
|
|
timeoutSetting = "SWW_RPROXY_TIMEOUT_MS"
|
|
defaultDisposition = "SWW_RPROXY_DEFAULT_DISPOSITION"
|
|
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
|
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
|
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
|
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
|
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
|
)
|
|
|
|
// The headers the tests set and look at.
|
|
const (
|
|
dispositionHeader = discriminator.DispositionHeader
|
|
forwardedForHeader = "X-Forwarded-For"
|
|
forwardedProtoHeader = "X-Forwarded-Proto"
|
|
requestIDHeader = "X-Request-ID"
|
|
asnHeader = "X-Client-Asn"
|
|
countryHeader = "X-Client-Country"
|
|
contentLengthHeader = "Content-Length"
|
|
// fromDiscriminatorHeader is a header the stand-in for discriminator adds to a deny.
|
|
fromDiscriminatorHeader = "X-From-Discriminator"
|
|
// addedHeaderValue is the value of a header a test adds to an answer, to see it
|
|
// reach the visitor.
|
|
addedHeaderValue = "yes"
|
|
)
|
|
|
|
// What the stand-in for discriminator answers.
|
|
const (
|
|
dispositionAllow = discriminator.DispositionAllow
|
|
dispositionDeny = discriminator.DispositionDeny
|
|
dispositionClose = discriminator.DispositionClose
|
|
// deniedBody is the body of a deny.
|
|
deniedBody = "refused by discriminator"
|
|
)
|
|
|
|
// output collects what rproxy writes on stdout.
|
|
type output struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
// Write adds lines rproxy writes.
|
|
func (o *output) Write(p []byte) (int, error) {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
|
|
return o.buf.Write(p)
|
|
}
|
|
|
|
// requestLine waits for the request log line of a test's one request.
|
|
func (o *output) requestLine(t *testing.T) map[string]any {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(waitLimit)
|
|
for time.Now().Before(deadline) {
|
|
o.mu.Lock()
|
|
text := o.buf.String()
|
|
o.mu.Unlock()
|
|
|
|
for line := range strings.Lines(text) {
|
|
var fields map[string]any
|
|
|
|
err := json.Unmarshal([]byte(line), &fields)
|
|
if err != nil {
|
|
t.Fatalf("output line %q is not JSON: %v", line, err)
|
|
}
|
|
|
|
if fields["type"] == "request" {
|
|
return fields
|
|
}
|
|
}
|
|
|
|
time.Sleep(pollInterval)
|
|
}
|
|
|
|
t.Fatal("no request log line")
|
|
|
|
return nil
|
|
}
|
|
|
|
// has reports whether what rproxy has written so far holds text.
|
|
func (o *output) has(text string) bool {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
|
|
return strings.Contains(o.buf.String(), text)
|
|
}
|
|
|
|
// waitFor waits until what rproxy has written holds text.
|
|
func (o *output) waitFor(t *testing.T, text string) {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(waitLimit)
|
|
for time.Now().Before(deadline) {
|
|
if o.has(text) {
|
|
return
|
|
}
|
|
|
|
time.Sleep(pollInterval)
|
|
}
|
|
|
|
t.Fatalf("rproxy did not write %q", text)
|
|
}
|
|
|
|
// heldLog is a request log whose Write waits until release is closed.
|
|
type heldLog struct {
|
|
release chan struct{}
|
|
}
|
|
|
|
// Write waits until release is closed.
|
|
func (l heldLog) Write(p []byte) (int, error) {
|
|
<-l.release
|
|
|
|
return len(p), nil
|
|
}
|
|
|
|
// question is what the stand-in for discriminator received as a question.
|
|
type question struct {
|
|
method, target, host string
|
|
header http.Header
|
|
body []byte
|
|
contentLength int64
|
|
transferEncoding []string
|
|
}
|
|
|
|
// Discriminator is a stand-in for discriminator that answers each question as
|
|
// answer does, and keeps the questions and the reports it gets.
|
|
type Discriminator struct {
|
|
*httptest.Server
|
|
|
|
questions chan question
|
|
reports chan discriminator.Report
|
|
}
|
|
|
|
// startDiscriminator starts a stand-in for discriminator.
|
|
func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator {
|
|
t.Helper()
|
|
|
|
s := &Discriminator{
|
|
questions: make(chan question, 10),
|
|
reports: make(chan discriminator.Report, 10),
|
|
}
|
|
s.Server = httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get(discriminator.ClientHeader) == "" {
|
|
s.takeReport(t, w, r)
|
|
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Errorf("read the question's body: %v", err)
|
|
}
|
|
|
|
s.questions <- question{
|
|
r.Method, r.RequestURI, r.Host, r.Header.Clone(), body, r.ContentLength,
|
|
r.TransferEncoding,
|
|
}
|
|
|
|
answer(w, r)
|
|
}))
|
|
t.Cleanup(s.Close)
|
|
|
|
return s
|
|
}
|
|
|
|
// takeReport keeps a report and answers it as discriminator does.
|
|
func (s *Discriminator) takeReport(
|
|
t *testing.T, w http.ResponseWriter, r *http.Request,
|
|
) {
|
|
t.Helper()
|
|
|
|
var report discriminator.Report
|
|
|
|
err := json.NewDecoder(r.Body).Decode(&report)
|
|
if err != nil || r.Method != http.MethodPost ||
|
|
r.URL.Path != discriminator.ReportPath {
|
|
t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
s.reports <- report
|
|
}
|
|
|
|
// wantNoReport checks that no report comes for a moment.
|
|
func (s *Discriminator) wantNoReport(t *testing.T) {
|
|
t.Helper()
|
|
|
|
select {
|
|
case report := <-s.reports:
|
|
t.Errorf("report %+v, want none", report)
|
|
case <-time.After(10 * pollInterval):
|
|
}
|
|
}
|
|
|
|
// report waits for a report.
|
|
func (s *Discriminator) report(t *testing.T) discriminator.Report {
|
|
t.Helper()
|
|
|
|
select {
|
|
case report := <-s.reports:
|
|
return report
|
|
case <-time.After(waitLimit):
|
|
t.Fatal("no report")
|
|
|
|
return discriminator.Report{}
|
|
}
|
|
}
|
|
|
|
// question waits for a question.
|
|
func (s *Discriminator) question(t *testing.T) question {
|
|
t.Helper()
|
|
|
|
select {
|
|
case asked := <-s.questions:
|
|
return asked
|
|
case <-time.After(waitLimit):
|
|
t.Fatal("no question")
|
|
|
|
return question{}
|
|
}
|
|
}
|
|
|
|
// startDiscriminatorAnsweringReports starts a stand-in for discriminator that
|
|
// answers each question as answer does and each report as report does, and
|
|
// returns its URL.
|
|
func startDiscriminatorAnsweringReports(
|
|
t *testing.T, answer, report http.HandlerFunc,
|
|
) string {
|
|
t.Helper()
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get(discriminator.ClientHeader) != "" {
|
|
answer(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
report(w, r)
|
|
}))
|
|
t.Cleanup(backend.Close)
|
|
|
|
return backend.URL
|
|
}
|
|
|
|
// allow answers a question with allow.
|
|
func allow(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionAllow)
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// deny answers a question with deny, status 403 and a body of its own.
|
|
func deny(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionDeny)
|
|
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = io.WriteString(w, deniedBody)
|
|
}
|
|
|
|
// startApp starts app as the app rproxy passes requests to.
|
|
func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
|
t.Helper()
|
|
|
|
server := httptest.NewServer(app)
|
|
t.Cleanup(server.Close)
|
|
|
|
return server
|
|
}
|
|
|
|
// greet is an app that reads the request's body and answers greeting.
|
|
func greet(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
_, _ = io.WriteString(w, greeting)
|
|
}
|
|
|
|
// startRProxy starts rproxy with discriminator at backend and the app at appURL, and
|
|
// the settings in env on top of the defaults. It returns where rproxy listens
|
|
// and what it writes.
|
|
func startRProxy(
|
|
t *testing.T, backend, appURL string, env map[string]string,
|
|
) (string, *output) {
|
|
t.Helper()
|
|
|
|
addr, out, _ := startServer(t, backend, appURL, env)
|
|
|
|
return addr, out
|
|
}
|
|
|
|
// startServer is startRProxy, and returns the server as well.
|
|
func startServer(
|
|
t *testing.T, backend, appURL string, env map[string]string,
|
|
) (string, *output, *rproxy.Server) {
|
|
t.Helper()
|
|
|
|
out := &output{}
|
|
addr, server := startServerLogging(t, backend, appURL, env, out, out)
|
|
|
|
return addr, out, server
|
|
}
|
|
|
|
// viperFrom returns a viper Set with each of settings, as the viper
|
|
// config.NewViper returns one bound to a process's environment.
|
|
func viperFrom(settings map[string]string) *viper.Viper {
|
|
v := viper.New()
|
|
for name, value := range settings {
|
|
v.Set(name, value)
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// startServerLogging is startServer with rproxy's request lines going to
|
|
// requestLog and its process lines to processLog.
|
|
func startServerLogging(
|
|
t *testing.T, backend, appURL string, env map[string]string,
|
|
requestLog, processLog io.Writer,
|
|
) (string, *rproxy.Server) {
|
|
t.Helper()
|
|
|
|
settings := map[string]string{
|
|
"SWW_RPROXY_WAF_BACKEND": backend, "SWW_RPROXY_UPSTREAM_URL": appURL,
|
|
}
|
|
maps.Copy(settings, env)
|
|
|
|
cfg, err := config.RProxyFromEnvironment(viperFrom(settings))
|
|
if err != nil {
|
|
t.Fatalf("settings %v: %v", settings, err)
|
|
}
|
|
|
|
server := rproxy.New(rproxy.Params{
|
|
Config: cfg,
|
|
RequestLog: requestLog,
|
|
ProcessLog: requestlog.NewProcessLogger(processLog, "test", slog.LevelInfo),
|
|
})
|
|
|
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
go func() {
|
|
_ = server.Serve(listener)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
_ = server.Close()
|
|
|
|
server.WaitForReports()
|
|
})
|
|
|
|
return listener.Addr().String(), server
|
|
}
|
|
|
|
// answered is the visitor's answer.
|
|
type answered struct {
|
|
status int
|
|
header http.Header
|
|
body string
|
|
}
|
|
|
|
// visit sends req as a visitor, on a connection of its own, and returns the
|
|
// answer.
|
|
func visit(t *testing.T, req *http.Request) answered {
|
|
t.Helper()
|
|
|
|
transport := &http.Transport{DisableKeepAlives: true, DisableCompression: true}
|
|
defer transport.CloseIdleConnections()
|
|
|
|
res, err := transport.RoundTrip(req)
|
|
if err != nil {
|
|
t.Fatalf("request: %v", err)
|
|
}
|
|
|
|
defer func() {
|
|
_ = res.Body.Close()
|
|
}()
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
return answered{res.StatusCode, res.Header, string(body)}
|
|
}
|
|
|
|
// newRequest returns a request for target at addr.
|
|
func newRequest(
|
|
t *testing.T, method, addr, target string, body io.Reader,
|
|
) *http.Request {
|
|
t.Helper()
|
|
|
|
req, err := http.NewRequestWithContext(t.Context(), method, "http://"+addr+target,
|
|
body)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
// get sends a GET for target at addr, and returns the answer.
|
|
func get(t *testing.T, addr, target string) answered {
|
|
t.Helper()
|
|
|
|
return visit(t, newRequest(t, http.MethodGet, addr, target, http.NoBody))
|
|
}
|
|
|
|
// wantGreeting checks that the answer is the app's.
|
|
func wantGreeting(t *testing.T, got answered) {
|
|
t.Helper()
|
|
|
|
if got.status != http.StatusOK || got.body != greeting {
|
|
t.Errorf("answered %d %q, want the app's greeting", got.status, got.body)
|
|
}
|
|
}
|
|
|
|
// dial opens a connection to addr, closed as the test ends.
|
|
func dial(t *testing.T, addr string) net.Conn {
|
|
t.Helper()
|
|
|
|
conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", addr)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_ = conn.Close()
|
|
})
|
|
|
|
err = conn.SetDeadline(time.Now().Add(waitLimit))
|
|
if err != nil {
|
|
t.Fatalf("set the deadline: %v", err)
|
|
}
|
|
|
|
return conn
|
|
}
|
|
|
|
// send writes text to conn.
|
|
func send(t *testing.T, conn net.Conn, text string) {
|
|
t.Helper()
|
|
|
|
_, err := io.WriteString(conn, text)
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
}
|
|
|
|
// readStatus reads the answer's status from conn.
|
|
func readStatus(t *testing.T, conn net.Conn) int {
|
|
t.Helper()
|
|
|
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
|
|
return res.StatusCode
|
|
}
|
|
|
|
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set(asnHeader, "AS64496")
|
|
w.Header().Set(countryHeader, "DE")
|
|
allow(w, r)
|
|
})
|
|
|
|
seen := make(chan *http.Request, 1)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
seen <- r.Clone(t.Context())
|
|
|
|
w.Header().Set("X-App", addedHeaderValue)
|
|
w.WriteHeader(http.StatusTeapot)
|
|
_, _ = io.WriteString(w, greeting)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x"
|
|
|
|
req := newRequest(t, http.MethodGet, addr, target, http.NoBody)
|
|
req.URL.Opaque = pathGoReencodes
|
|
req.Host = site
|
|
req.Header.Set(asnHeader, "AS1")
|
|
req.Header.Set(countryHeader, "XX")
|
|
req.Header.Set(requestIDHeader, "the-visitor's")
|
|
|
|
got := visit(t, req)
|
|
if got.status != http.StatusTeapot || got.body != greeting ||
|
|
got.header.Get("X-App") != addedHeaderValue {
|
|
t.Errorf("answered %+v, want the app's answer", got)
|
|
}
|
|
|
|
received := <-seen
|
|
asked := standIn.question(t)
|
|
|
|
if received.RequestURI != target || received.Host != site {
|
|
t.Errorf("the app saw %s for %s", received.RequestURI, received.Host)
|
|
}
|
|
|
|
for name, want := range map[string]string{
|
|
asnHeader: "AS64496",
|
|
countryHeader: "DE",
|
|
forwardedForHeader: localhost,
|
|
requestIDHeader: asked.header.Get(requestIDHeader),
|
|
} {
|
|
if received.Header.Get(name) != want {
|
|
t.Errorf("the app saw %s %q, want %q", name, received.Header.Get(name), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
removed := []string{
|
|
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri",
|
|
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert",
|
|
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
|
|
}
|
|
|
|
standIn := startDiscriminator(t, allow)
|
|
seen := make(chan http.Header, 1)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
seen <- r.Header.Clone()
|
|
|
|
greet(w, r)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
trustedProxies: "10.0.0.0/8",
|
|
})
|
|
|
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
req.Host = site
|
|
req.Header.Set(forwardedForHeader, "198.51.100.1")
|
|
req.Header.Set("X-Forwarded-Host", "claimed.example")
|
|
req.Header.Set(forwardedProtoHeader, "https")
|
|
|
|
for _, name := range removed {
|
|
req.Header.Set(name, "claimed")
|
|
}
|
|
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
header := <-seen
|
|
|
|
for name, want := range map[string]string{
|
|
forwardedForHeader: localhost,
|
|
"X-Forwarded-Host": site,
|
|
forwardedProtoHeader: "http",
|
|
} {
|
|
if got := header.Values(name); !slices.Equal(got, []string{want}) {
|
|
t.Errorf("the app saw %s %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
|
|
for _, name := range removed {
|
|
if got := header.Values(name); len(got) != 0 {
|
|
t.Errorf("the app saw %s %q, want none", name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, allow)
|
|
seen := make(chan http.Header, 1)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
seen <- r.Header.Clone()
|
|
|
|
greet(w, r)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
trustedProxies: localhost + "/32",
|
|
})
|
|
|
|
sent := map[string]string{
|
|
"Forwarded": "for=198.51.100.1",
|
|
"X-Forwarded-Host": "public.example",
|
|
forwardedProtoHeader: "https",
|
|
"X-Real-Ip": "198.51.100.1",
|
|
}
|
|
|
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
req.Host = site
|
|
|
|
for name, value := range sent {
|
|
req.Header.Set(name, value)
|
|
}
|
|
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
header := <-seen
|
|
|
|
for name, want := range sent {
|
|
if got := header.Values(name); !slices.Equal(got, []string{want}) {
|
|
t.Errorf("the app saw %s %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header()["Content-Type"] = nil
|
|
_, _ = io.WriteString(w, "<html>")
|
|
})
|
|
_, _, server := startServer(t, standIn.URL, app.URL, nil)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
|
|
server.Handler.ServeHTTP(w, req)
|
|
|
|
// Go's server guesses a type when the first part of the body goes before the
|
|
// headers are flushed, which is a matter of timing here. So this looks for what
|
|
// stops it: the key with no value.
|
|
values, there := w.Header()["Content-Type"]
|
|
if !there || len(values) != 0 {
|
|
t.Errorf("the answer's Content-Type is %q, there: %v, want the key with no value",
|
|
values, there)
|
|
}
|
|
}
|
|
|
|
func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
backend func(t *testing.T) string
|
|
}{
|
|
{"discriminator allows", func(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
return startDiscriminator(t, allow).URL
|
|
}},
|
|
{"discriminator is down", func(*testing.T) string { return nowhere }},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
seen := make(chan http.Header, 1)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
seen <- r.Header.Clone()
|
|
|
|
greet(w, r)
|
|
})
|
|
addr, _ := startRProxy(t, tc.backend(t), app.URL, nil)
|
|
|
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
req.Header.Set(asnHeader, "AS1")
|
|
req.Header.Set(countryHeader, "XX")
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
header := <-seen
|
|
if len(header.Values(asnHeader)) != 0 || len(header.Values(countryHeader)) != 0 {
|
|
t.Errorf("the app saw the visitor's lookup headers: %v", header)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, deny)
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != http.StatusForbidden || got.body != deniedBody ||
|
|
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
|
|
t.Errorf("answered %+v, want discriminator's answer", got)
|
|
}
|
|
|
|
if len(got.header.Values(dispositionHeader)) != 0 {
|
|
t.Errorf("the visitor got %s", dispositionHeader)
|
|
}
|
|
|
|
report := standIn.report(t)
|
|
if report.Status != http.StatusForbidden ||
|
|
report.ResponseBytes != int64(len(deniedBody)) ||
|
|
report.DurationUpstreamTotal != 0 {
|
|
t.Errorf("report %+v, of a request denied", report)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["disposition"] != dispositionDeny || line["default_disposition"] != nil {
|
|
t.Errorf("line %v, want discriminator's deny", line)
|
|
}
|
|
}
|
|
|
|
func TestADeniedHeadKeepsDiscriminatorsContentLength(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, deny)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
|
|
if got.status != http.StatusForbidden ||
|
|
got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) {
|
|
t.Errorf("answered %+v, want discriminator's 403 and its length", got)
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const host = " HTTP/1.1\r\nHost: site.example\r\n\r\n"
|
|
|
|
for _, tc := range []struct {
|
|
name, request, body string
|
|
status int
|
|
}{
|
|
{"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
|
|
{"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest},
|
|
{"the health check", "GET " + discriminator.HealthPath + host, "ok\n", http.StatusOK},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
log := heldLog{release: make(chan struct{})}
|
|
standIn := startDiscriminator(t, deny)
|
|
app := startApp(t, greet)
|
|
addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
|
|
|
|
t.Cleanup(func() { close(log.release) })
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, tc.request)
|
|
|
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
defer func() {
|
|
_ = res.Body.Close()
|
|
}()
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil || res.StatusCode != tc.status || string(body) != tc.body {
|
|
t.Errorf("answered %d %q (%v), want %d %q", res.StatusCode, body, err,
|
|
tc.status, tc.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET x:admin HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
|
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
|
|
if res.Header.Get("Content-Type") != "text/plain; charset=utf-8" ||
|
|
res.Header.Get("X-Content-Type-Options") != "nosniff" {
|
|
t.Errorf("the answer's headers are %v", res.Header)
|
|
}
|
|
}
|
|
|
|
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionClose)
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
|
|
text, err := io.ReadAll(conn)
|
|
if err != nil || len(text) != 0 {
|
|
t.Errorf("read %q (%v), want the connection closed without an answer",
|
|
text, err)
|
|
}
|
|
|
|
report := standIn.report(t)
|
|
if report.Status != 0 || report.ResponseBytes != 0 {
|
|
t.Errorf("report %+v, of a visitor sent nothing", report)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["disposition"] != dispositionClose || line["status"] != 0.0 {
|
|
t.Errorf("line %v, want close and status 0", line)
|
|
}
|
|
}
|
|
|
|
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
backend := startDiscriminatorAnsweringReports(t,
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionClose)
|
|
},
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
// A negative time would be about a microsecond, which one request would seldom
|
|
// show.
|
|
for range 100 {
|
|
addr, out := startRProxy(t, backend, nowhere, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
|
|
line := out.requestLine(t)
|
|
|
|
duration, ok := line["duration_response"].(float64)
|
|
if !ok || duration < 0 {
|
|
t.Errorf("line %v, want a duration_response that is not negative", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// answeringWith returns what starts a stand-in for discriminator that answers each
|
|
// question as answer does, and returns its URL.
|
|
func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
|
|
return func(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
return startDiscriminator(t, answer).URL
|
|
}
|
|
}
|
|
|
|
// noAnswers are the ways discriminator can leave the decision to the default.
|
|
func noAnswers() map[string]func(t *testing.T) string {
|
|
return map[string]func(t *testing.T) string{
|
|
"no connection": func(*testing.T) string { return nowhere },
|
|
"no answer at all": answeringWith(func(http.ResponseWriter, *http.Request) {
|
|
panic(http.ErrAbortHandler)
|
|
}),
|
|
"a slow start": answeringWith(func(_ http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
}),
|
|
"headers followed by a stalled body": answeringWith(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionDeny)
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_ = http.NewResponseController(w).Flush()
|
|
|
|
<-r.Context().Done()
|
|
}),
|
|
"an answer cut short": answeringWith(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionDeny)
|
|
w.Header().Set(contentLengthHeader, "100")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = io.WriteString(w, deniedBody)
|
|
// Go's server drops on this panic what it has not flushed.
|
|
_ = http.NewResponseController(w).Flush()
|
|
|
|
panic(http.ErrAbortHandler)
|
|
}),
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name, disposition, body string
|
|
status int
|
|
}{
|
|
{"a refusal", dispositionDeny, deniedBody, http.StatusForbidden},
|
|
{"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests},
|
|
{"discriminator's 400", "", "refused\n", http.StatusBadRequest},
|
|
{"a 500", "", "broken\n", http.StatusInternalServerError},
|
|
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
|
|
{"no header", "", "who knows\n", http.StatusOK},
|
|
{"an unknown disposition", "maybe", "who knows\n", http.StatusOK},
|
|
} {
|
|
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
|
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
if tc.disposition != "" {
|
|
w.Header().Set(dispositionHeader, tc.disposition)
|
|
}
|
|
|
|
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
|
|
w.WriteHeader(tc.status)
|
|
_, _ = io.WriteString(w, tc.body)
|
|
})
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
defaultDisposition: setting,
|
|
})
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != tc.status || got.body != tc.body ||
|
|
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
|
|
t.Errorf("answered %+v, want discriminator's answer", got)
|
|
}
|
|
|
|
if len(got.header.Values(dispositionHeader)) != 0 {
|
|
t.Errorf("the visitor got %s", dispositionHeader)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["status"] != float64(tc.status) || line["default_disposition"] != nil {
|
|
t.Errorf("line %v, want status %d and no default", line, tc.status)
|
|
}
|
|
|
|
if out.has("discriminator gave no answer") {
|
|
t.Error("rproxy warned that discriminator gave no answer")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "refused", http.StatusBadRequest)
|
|
})
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
|
|
t.Errorf("answered %+v, want discriminator's 400", got)
|
|
}
|
|
|
|
if line := out.requestLine(t); line["disposition"] != nil {
|
|
t.Errorf("line %v, want no disposition", line)
|
|
}
|
|
|
|
standIn.wantNoReport(t)
|
|
}
|
|
|
|
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header()["Content-Type"] = nil
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = io.WriteString(w, "<html>broken")
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
|
|
|
got := get(t, addr, "/")
|
|
if contentType, sent := got.header["Content-Type"]; sent {
|
|
t.Errorf("the visitor got the Content-Type %v", contentType)
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const size = 1 << 20
|
|
|
|
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write(make([]byte, size))
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != http.StatusInternalServerError || len(got.body) != size {
|
|
t.Errorf("answered %d with %d bytes, want discriminator's 500 with %d",
|
|
got.status, len(got.body), size)
|
|
}
|
|
}
|
|
|
|
// startDiscriminatorWriting starts a stand-in for discriminator that reads
|
|
// each question and answers it with text, as it is, and returns its URL. It
|
|
// can send what Go's server would not.
|
|
func startDiscriminatorWriting(t *testing.T, text string) string {
|
|
t.Helper()
|
|
|
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_ = listener.Close()
|
|
})
|
|
|
|
go func() {
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_ = conn.SetDeadline(time.Now().Add(waitLimit))
|
|
_, _ = http.ReadRequest(bufio.NewReader(conn))
|
|
_, _ = io.WriteString(conn, text)
|
|
_ = conn.Close()
|
|
}
|
|
}()
|
|
|
|
return "http://" + listener.Addr().String()
|
|
}
|
|
|
|
// overOneMiB answers a question with a body over the most rproxy reads, and with
|
|
// disposition unless it is "".
|
|
func overOneMiB(disposition string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
if disposition != "" {
|
|
w.Header().Set(dispositionHeader, disposition)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write(make([]byte, 1<<20+1))
|
|
}
|
|
}
|
|
|
|
// switchAndHold answers a 101 and holds the connection open until rproxy closes
|
|
// it, or until long after any test has stopped waiting.
|
|
func switchAndHold(w http.ResponseWriter, _ *http.Request) {
|
|
conn, _, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
_ = conn.SetDeadline(time.Now().Add(2 * waitLimit))
|
|
_, _ = io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n"+
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
_, _ = io.Copy(io.Discard, conn)
|
|
}
|
|
|
|
func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, backend := range map[string]func(t *testing.T) string{
|
|
"a deny over 1 MiB": answeringWith(overOneMiB(dispositionDeny)),
|
|
"an allow over 1 MiB": answeringWith(overOneMiB(dispositionAllow)),
|
|
"no header over 1 MiB": answeringWith(overOneMiB("")),
|
|
"a status below 100": func(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
return startDiscriminatorWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
|
|
},
|
|
"a 101 that holds its connection open": answeringWith(switchAndHold),
|
|
} {
|
|
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
|
t.Run(name+" with the default "+setting, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
|
|
defaultDisposition: setting,
|
|
})
|
|
|
|
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
|
t.Errorf("answered %+v, want 502", got)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["status"] != float64(http.StatusBadGateway) ||
|
|
line["disposition"] != nil || line["default_disposition"] != nil {
|
|
t.Errorf("line %v, want status 502 and no disposition", line)
|
|
}
|
|
|
|
out.waitFor(t, "discriminator's answer is dropped")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, overOneMiB(dispositionDeny))
|
|
addr, out := startRProxy(t, standIn.URL, nowhere, nil)
|
|
|
|
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
|
t.Errorf("answered %+v, want 502", got)
|
|
}
|
|
|
|
out.requestLine(t)
|
|
standIn.wantNoReport(t)
|
|
}
|
|
|
|
func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, backend := range noAnswers() {
|
|
t.Run("fails open on "+name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
|
|
timeoutSetting: "200",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
|
|
line := out.requestLine(t)
|
|
if line["disposition"] != dispositionAllow ||
|
|
line["default_disposition"] != true {
|
|
t.Errorf("line %v, want allow by default", line)
|
|
}
|
|
})
|
|
|
|
t.Run("fails closed on "+name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
|
|
timeoutSetting: "200",
|
|
defaultDisposition: dispositionDeny,
|
|
})
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != http.StatusServiceUnavailable {
|
|
t.Errorf("answered %+v, want 503", got)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["disposition"] != dispositionDeny ||
|
|
line["default_disposition"] != true {
|
|
t.Errorf("line %v, want deny by default", line)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAVisitorThatGoesAwayWhileDiscriminatorIsAskedGetsNoDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
|
t.Run("with the default "+disposition, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
})
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
timeoutSetting: "60000",
|
|
defaultDisposition: disposition,
|
|
})
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
standIn.question(t)
|
|
|
|
_ = conn.Close()
|
|
|
|
line := out.requestLine(t)
|
|
if line["status"] != 0.0 || line["disposition"] != nil ||
|
|
line["default_disposition"] != nil {
|
|
t.Errorf("line %v, want status 0 and no disposition", line)
|
|
}
|
|
|
|
if out.has("discriminator gave no answer") {
|
|
t.Error("rproxy warned that discriminator gave no answer")
|
|
}
|
|
|
|
standIn.wantNoReport(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.waitFor(t, "discriminator gave no answer")
|
|
}
|
|
|
|
func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
})
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
timeoutSetting: "200",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.requestLine(t)
|
|
standIn.wantNoReport(t)
|
|
}
|
|
|
|
func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, _ := startRProxy(t, nowhere, app.URL, nil)
|
|
|
|
got := get(t, addr, "/_sww/metrics")
|
|
if got.status != http.StatusNotFound {
|
|
t.Errorf("answered %+v, want 404", got)
|
|
}
|
|
}
|
|
|
|
func TestHealthCheckIsAnsweredWhileDiscriminatorIsDown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
|
|
|
got := get(t, addr, discriminator.HealthPath)
|
|
if got.status != http.StatusOK || got.body != "ok\n" {
|
|
t.Errorf("answered %+v, want ok", got)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["path"] != discriminator.HealthPath || line["disposition"] != nil {
|
|
t.Errorf("line %v, want one with no disposition", line)
|
|
}
|
|
|
|
// With discriminator up too, it is not asked.
|
|
addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
|
|
get(t, addr, discriminator.HealthPath)
|
|
|
|
if len(standIn.questions) != 0 {
|
|
t.Error("discriminator was asked about the health check")
|
|
}
|
|
}
|
|
|
|
func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
release := make(chan struct{})
|
|
backend := startDiscriminatorAnsweringReports(t, deny,
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
select {
|
|
case <-release:
|
|
case <-r.Context().Done():
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, backend, app.URL, map[string]string{
|
|
timeoutSetting: "60000",
|
|
})
|
|
|
|
t.Cleanup(func() { close(release) })
|
|
|
|
// Go's server reads the next request on a connection only once the handler has
|
|
// returned, so the second answer comes only if the handler does not wait for
|
|
// the first report, which the stand-in leaves unanswered.
|
|
conn := dial(t, addr)
|
|
reader := bufio.NewReader(conn)
|
|
|
|
for range 2 {
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
|
|
res, err := http.ReadResponse(reader, nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
_, _ = io.Copy(io.Discard, res.Body)
|
|
_ = res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusForbidden {
|
|
t.Errorf("status %d, want discriminator's 403", res.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAReportDiscriminatorLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
release := make(chan struct{})
|
|
backend := startDiscriminatorAnsweringReports(t, allow,
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
<-release
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
app := startApp(t, greet)
|
|
// The time limit is long enough for a loaded host to answer the question before
|
|
// it runs out.
|
|
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
|
timeoutSetting: "3000",
|
|
})
|
|
|
|
t.Cleanup(func() { close(release) })
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.waitFor(t, "a report is dropped")
|
|
}
|
|
|
|
func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
backend := startDiscriminatorAnsweringReports(t, allow,
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
})
|
|
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, backend, app.URL, nil)
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.waitFor(t, "a report is dropped")
|
|
}
|
|
|
|
func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
backend := startDiscriminatorAnsweringReports(t, allow, switchAndHold)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
|
timeoutSetting: "60000",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.waitFor(t, "a report is dropped")
|
|
}
|
|
|
|
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out, server := startServer(t, standIn.URL, app.URL, nil)
|
|
|
|
server.WaitForReports()
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.requestLine(t)
|
|
standIn.wantNoReport(t)
|
|
}
|
|
|
|
func TestReportGivesWhatHappened(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const body = "the visitor's body"
|
|
|
|
standIn := startDiscriminator(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
|
strings.NewReader(body))))
|
|
|
|
report := standIn.report(t)
|
|
|
|
if report.RequestID == "" || report.Status != http.StatusOK ||
|
|
report.RequestBytes != int64(len(body)) ||
|
|
report.ResponseBytes != int64(len(greeting)) ||
|
|
report.DurationTotal <= 0 || report.DurationUpstreamTotal <= 0 ||
|
|
report.DurationUpstreamTotal > report.DurationTotal {
|
|
t.Errorf("report %+v, of %d bytes up and %d back", report, len(body), len(greeting))
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["request_id"] != report.RequestID {
|
|
t.Errorf("line %v, report %+v, want the same request id", line, report)
|
|
}
|
|
}
|
|
|
|
func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reportGotThere := make(chan struct{})
|
|
release := make(chan struct{})
|
|
backend := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get(discriminator.ClientHeader) != "" {
|
|
allow(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
close(reportGotThere)
|
|
|
|
select {
|
|
case <-release:
|
|
case <-r.Context().Done():
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
t.Cleanup(backend.Close)
|
|
|
|
app := startApp(t, greet)
|
|
addr, _, server := startServer(t, backend.URL, app.URL, map[string]string{
|
|
timeoutSetting: "5000",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
|
|
select {
|
|
case <-reportGotThere:
|
|
case <-time.After(waitLimit):
|
|
t.Fatal("no report")
|
|
}
|
|
|
|
waited := make(chan struct{})
|
|
|
|
go func() {
|
|
server.WaitForReports()
|
|
close(waited)
|
|
}()
|
|
|
|
select {
|
|
case <-waited:
|
|
t.Error("it did not wait for the report")
|
|
case <-time.After(10 * pollInterval):
|
|
}
|
|
|
|
close(release)
|
|
|
|
select {
|
|
case <-waited:
|
|
case <-time.After(waitLimit):
|
|
t.Error("it still waits, the report having been answered")
|
|
}
|
|
}
|
|
|
|
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(t, allow)
|
|
app := startApp(t, echoAfterUpgrade)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxRequestTime: "2000",
|
|
maxResponseTime: "2000",
|
|
})
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
|
|
reader := bufio.NewReader(conn)
|
|
|
|
res, err := http.ReadResponse(reader, nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer to the upgrade: %v", err)
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusSwitchingProtocols {
|
|
t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols)
|
|
}
|
|
|
|
// Past both time limits, the connection still carries what is sent.
|
|
time.Sleep(2100 * time.Millisecond)
|
|
|
|
const message = "still here\n"
|
|
|
|
send(t, conn, message)
|
|
|
|
echoed, err := reader.ReadString('\n')
|
|
if err != nil || echoed != message {
|
|
t.Errorf("echo %q (%v), want %q", echoed, err, message)
|
|
}
|
|
|
|
_ = conn.Close()
|
|
|
|
report := standIn.report(t)
|
|
if report.Status != http.StatusSwitchingProtocols ||
|
|
report.RequestBytes != int64(len(message)) ||
|
|
report.ResponseBytes != int64(len(message)) {
|
|
t.Errorf("report %+v, of %d bytes each way", report, len(message))
|
|
}
|
|
|
|
if line := out.requestLine(t); line["status"] != float64(res.StatusCode) {
|
|
t.Errorf("line %v, want status 101", line)
|
|
}
|
|
}
|
|
|
|
func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const goodbye = "sent after the visitor stopped sending\n"
|
|
|
|
standIn := startDiscriminator(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
_ = buffered.Flush()
|
|
|
|
_, _ = io.Copy(io.Discard, buffered)
|
|
|
|
_, _ = buffered.WriteString(goodbye)
|
|
_ = buffered.Flush()
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
|
|
reader := bufio.NewReader(conn)
|
|
|
|
res, err := http.ReadResponse(reader, nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer to the upgrade: %v", err)
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
|
|
tcpConn, ok := conn.(*net.TCPConn)
|
|
if !ok {
|
|
t.Fatalf("the connection is a %T", conn)
|
|
}
|
|
|
|
err = tcpConn.CloseWrite()
|
|
if err != nil {
|
|
t.Fatalf("stop sending: %v", err)
|
|
}
|
|
|
|
text, err := io.ReadAll(reader)
|
|
if err != nil || string(text) != goodbye {
|
|
t.Errorf("read %q (%v), want %q", text, err, goodbye)
|
|
}
|
|
}
|
|
|
|
// This test does not run in parallel. Go closes a connection nothing refers to at
|
|
// its next garbage collection, which other tests' allocations would soon start, and
|
|
// that would hide a connection left open.
|
|
//
|
|
//nolint:paralleltest // see above
|
|
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
|
|
closed := make(chan error, 1)
|
|
standIn := startDiscriminator(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
closed <- err
|
|
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
// Not the protocol the visitor asked for, so ReverseProxy refuses the switch.
|
|
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
"Connection: Upgrade\r\nUpgrade: another\r\n\r\n")
|
|
_ = buffered.Flush()
|
|
|
|
_ = conn.SetReadDeadline(time.Now().Add(waitLimit))
|
|
|
|
_, err = buffered.ReadByte()
|
|
closed <- err
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
|
|
if status := readStatus(t, conn); status != http.StatusBadGateway {
|
|
t.Errorf("status %d, want 502", status)
|
|
}
|
|
|
|
err := <-closed
|
|
if !errors.Is(err, io.EOF) {
|
|
t.Errorf("the app's read gave %v, want its connection closed", err)
|
|
}
|
|
}
|
|
|
|
// echoAfterUpgrade is an app that switches protocols on request, and then sends
|
|
// back each line it receives.
|
|
func echoAfterUpgrade(w http.ResponseWriter, _ *http.Request) {
|
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
_ = buffered.Flush()
|
|
|
|
for {
|
|
line, err := buffered.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, _ = buffered.WriteString(line)
|
|
_ = buffered.Flush()
|
|
}
|
|
}
|
|
|
|
func TestEachRequestGetsAFreshID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const fromTraefik = "an-id-from-traefik"
|
|
|
|
standIn := startDiscriminator(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
trustedProxies: localhost + "/32",
|
|
})
|
|
|
|
ids := make([]string, 0, 2)
|
|
|
|
for range 2 {
|
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
req.Header.Set(requestIDHeader, fromTraefik)
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
ids = append(ids, standIn.question(t).header.Get(requestIDHeader))
|
|
}
|
|
|
|
if slices.Contains(ids, fromTraefik) || ids[0] == "" || ids[0] == ids[1] {
|
|
t.Errorf("ids %v, want two fresh ones", ids)
|
|
}
|
|
}
|
|
|
|
func TestLogLineGivesTheRequestAsSent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
path = "/search%2Fall"
|
|
query = "q=secret&token=%zz"
|
|
body = "the visitor's body"
|
|
)
|
|
|
|
standIn := startDiscriminator(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
req := newRequest(t, http.MethodPost, addr, path+"?"+query, strings.NewReader(body))
|
|
req.Host = site
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
line := out.requestLine(t)
|
|
asked := standIn.question(t)
|
|
|
|
for name, want := range map[string]any{
|
|
"type": "request",
|
|
"request_id": asked.header.Get(requestIDHeader),
|
|
"client_ip": localhost,
|
|
"host": site,
|
|
"method": http.MethodPost,
|
|
"path": path,
|
|
"query": query,
|
|
"status": float64(http.StatusOK),
|
|
"request_bytes": float64(len(body)),
|
|
"response_bytes": float64(len(greeting)),
|
|
"disposition": dispositionAllow,
|
|
} {
|
|
if line[name] != want {
|
|
t.Errorf("%s is %v, want %v", name, line[name], want)
|
|
}
|
|
}
|
|
|
|
if line["default_disposition"] != nil {
|
|
t.Errorf("default_disposition is %v, want it left out", line["default_disposition"])
|
|
}
|
|
|
|
for _, name := range []string{"duration_request", "duration_response"} {
|
|
duration, ok := line[name].(float64)
|
|
if !ok || duration <= 0 {
|
|
t.Errorf("%s is %v, want a time", name, line[name])
|
|
}
|
|
}
|
|
|
|
text, _ := line["time"].(string)
|
|
|
|
_, err := time.Parse(time.RFC3339, text)
|
|
if err != nil {
|
|
t.Errorf("time: %v", err)
|
|
}
|
|
}
|