Rename the program to sww and its deciding daemon to discriminator
check / check (push) Successful in 3m13s
check / check (push) Successful in 3m13s
The program is now sww, built from cmd/sww, with two modes: sww rproxy and sww discriminator. servewaf becomes discriminator everywhere it names that daemon: the package (internal/discriminator), its types, functions, files, the discriminator_line log field, and its docs and tests. smallwebwaf is left only where sneak's directive allows it: the documents' product name, the git.eeqj.de/sneak/smallwebwaf URLs, and the Go module path sneak.berlin/go/smallwebwaf. Everywhere else a name containing it takes the sww form: the package (internal/sww), metric names (sww_*), the X-Sww-* headers, the /_sww/ admin endpoints, the OS user, and the state and rules directories. The SWWAF_ environment variables take the SWW_ form. No logic changes. make check passes. Model: deepseek-v4-pro
This commit is contained in:
1 parent
2e2f6f7175
commit
42643be662
106 files changed
+3289
-3262
No files matched your search
@@ -0,0 +1,791 @@
|
||||
package sww_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||
)
|
||||
|
||||
const (
|
||||
// wafBackend is the setting that names discriminator to rproxy.
|
||||
wafBackend = "SWRP_WAF_BACKEND"
|
||||
// rproxyListenAddr is the setting that says where rproxy listens.
|
||||
rproxyListenAddr = "SWRP_LISTEN_ADDR"
|
||||
// rproxyUpstreamURL is the setting that says where the app is.
|
||||
rproxyUpstreamURL = "SWRP_UPSTREAM_URL"
|
||||
// rproxyTimeout is the setting that says how long rproxy waits for discriminator.
|
||||
rproxyTimeout = "SWRP_TIMEOUT_MS"
|
||||
// bannedProbe is a path the default rule file bans a client for.
|
||||
bannedProbe = "/.env"
|
||||
// otherClient is a client the tests do not ban.
|
||||
otherClient = "198.51.100.7"
|
||||
)
|
||||
|
||||
// runRProxy runs `sww rproxy` with the settings in env until ctx is
|
||||
// done, and returns its exit status.
|
||||
func runRProxy(ctx context.Context, env map[string]string, out *output) int {
|
||||
return sww.RunRProxy(ctx, sww.Params{
|
||||
Version: testVersion,
|
||||
LookupEnv: func(name string) (string, bool) {
|
||||
value, ok := env[name]
|
||||
|
||||
return value, ok
|
||||
},
|
||||
Stdout: out,
|
||||
})
|
||||
}
|
||||
|
||||
// startRProxy runs rproxy with discriminator at backend and the app at appURL, and
|
||||
// returns its URL and what it writes. It believes X-Forwarded-For from the
|
||||
// tests. It stops as the test ends, and that it stopped in order is checked.
|
||||
func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
|
||||
t.Helper()
|
||||
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
out := &output{}
|
||||
exited := make(chan int, 1)
|
||||
|
||||
go func() {
|
||||
exited <- runRProxy(ctx, map[string]string{
|
||||
rproxyListenAddr: localhost + ":0",
|
||||
"SWRP_UPSTREAM_URL": appURL,
|
||||
"SWRP_TRUSTED_PROXIES": localhost + "/32",
|
||||
wafBackend: backend,
|
||||
}, out)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
stop()
|
||||
|
||||
select {
|
||||
case status := <-exited:
|
||||
if status != 0 {
|
||||
t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text())
|
||||
}
|
||||
case <-time.After(waitLimit):
|
||||
t.Error("rproxy still running after being told to stop")
|
||||
}
|
||||
|
||||
out.line(t, "msg", "stopped")
|
||||
})
|
||||
|
||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||
|
||||
return "http://" + addr, out
|
||||
}
|
||||
|
||||
// startDiscriminator runs discriminator with the settings in env, and returns
|
||||
// its address and URL, what it writes, and what stops it, which checks that it
|
||||
// stopped in order.
|
||||
func startDiscriminator(t *testing.T, env map[string]string) (string, *output, func()) {
|
||||
t.Helper()
|
||||
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
out := &output{}
|
||||
exited := make(chan int, 1)
|
||||
|
||||
go func() {
|
||||
exited <- run(ctx, env, out)
|
||||
}()
|
||||
|
||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||
|
||||
return addr, out, func() {
|
||||
stop()
|
||||
|
||||
select {
|
||||
case status := <-exited:
|
||||
if status != 0 {
|
||||
t.Fatalf("discriminator's exit status %d, want 0; output:\n%s", status, out.text())
|
||||
}
|
||||
case <-time.After(waitLimit):
|
||||
t.Fatal("discriminator still running after being told to stop")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// discriminatorSettings are discriminator's settings with the default rule
|
||||
// file, to listen at any free port.
|
||||
func discriminatorSettings(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
return map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
stateDir: t.TempDir(),
|
||||
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t))
|
||||
defer stop()
|
||||
|
||||
backend := "http://" + addr
|
||||
first, firstOut := startRProxy(t, backend, startApp(t))
|
||||
second, _ := startRProxy(t, backend, startApp(t))
|
||||
|
||||
wantStatus(t, first+"/", placed, http.StatusOK)
|
||||
wantStatus(t, second+"/", placed, http.StatusOK)
|
||||
|
||||
// The default rule file bans a probe for this.
|
||||
wantStatus(t, first+bannedProbe, placed, http.StatusForbidden)
|
||||
wantStatus(t, second+"/", placed, http.StatusForbidden)
|
||||
wantStatus(t, second+"/", otherClient, http.StatusOK)
|
||||
|
||||
// One request's three lines carry one id, rproxy's with discriminator's answer.
|
||||
line := firstOut.line(t, "path", bannedProbe)
|
||||
if line["disposition"] != discriminator.DispositionDeny ||
|
||||
line["client_ip"] != placed {
|
||||
t.Errorf("rproxy's line is %v", line)
|
||||
}
|
||||
|
||||
id, _ := line["request_id"].(string)
|
||||
decision := discriminatorLine(t, discriminatorOut, id, "decision")
|
||||
report := discriminatorLine(t, discriminatorOut, id, "report")
|
||||
|
||||
if decision["host"] != line["host"] || decision["client_ip"] != placed ||
|
||||
report["status"] != float64(http.StatusForbidden) {
|
||||
t.Errorf("discriminator's lines are %v and %v", decision, report)
|
||||
}
|
||||
}
|
||||
|
||||
// discriminatorLine waits for discriminator's line with request id and
|
||||
// discriminator_line kind.
|
||||
func discriminatorLine(t *testing.T, out *output, id, kind string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for time.Now().Before(deadline) {
|
||||
line := out.findLine(t, func(fields map[string]any) bool {
|
||||
return fields["request_id"] == id && fields["discriminator_line"] == kind
|
||||
})
|
||||
if line != nil {
|
||||
return line
|
||||
}
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
|
||||
t.Fatalf("no %s line for %s in:\n%s", kind, id, out.text())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTheSiteAnswersWhileDiscriminatorIsStoppedAndStartedAgain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := discriminatorSettings(t)
|
||||
addr, _, stop := startDiscriminator(t, env)
|
||||
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
||||
|
||||
// A visitor who keeps asking throughout, and is never refused.
|
||||
visitorDone := make(chan struct{})
|
||||
visitorStop := make(chan struct{})
|
||||
|
||||
var refused atomic.Int64
|
||||
|
||||
go func() {
|
||||
defer close(visitorDone)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-visitorStop:
|
||||
return
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
|
||||
if !answersNormally(t.Context(), site+"/", otherClient) {
|
||||
refused.Add(1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wantStatus(t, site+bannedProbe, placed, http.StatusForbidden)
|
||||
wantStatus(t, site+"/", placed, http.StatusForbidden)
|
||||
stop()
|
||||
|
||||
// With discriminator down, the site answers, and rproxy says the default did it.
|
||||
wantStatus(t, site+"/after-stop", placed, http.StatusOK)
|
||||
|
||||
line := out.line(t, "path", "/after-stop")
|
||||
if line["disposition"] != discriminator.DispositionAllow ||
|
||||
line["default_disposition"] != true {
|
||||
t.Errorf("rproxy's line is %v", line)
|
||||
}
|
||||
|
||||
// Back at its address, with its state files, it applies the ban again.
|
||||
restarted := maps.Clone(env)
|
||||
restarted[listenAddr] = addr
|
||||
_, _, stop = startDiscriminator(t, restarted)
|
||||
|
||||
defer stop()
|
||||
|
||||
wantStatus(t, site+"/", placed, http.StatusForbidden)
|
||||
|
||||
close(visitorStop)
|
||||
<-visitorDone
|
||||
|
||||
if refused.Load() != 0 {
|
||||
t.Errorf("the site refused its visitor %d times", refused.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// answersNormally reports whether a request to url from the client at from, as
|
||||
// X-Forwarded-For names it, gets the app's answer.
|
||||
func answersNormally(ctx context.Context, url, from string) bool {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
req.Header.Set("X-Forwarded-For", from)
|
||||
|
||||
transport := &http.Transport{}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
res, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
|
||||
return err == nil && res.StatusCode == http.StatusOK && string(body) == greeting
|
||||
}
|
||||
|
||||
// sendRaw sends request, as it is, to rproxy at site, and returns the status of
|
||||
// the answer.
|
||||
func sendRaw(t *testing.T, site, request string) int {
|
||||
t.Helper()
|
||||
|
||||
conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp",
|
||||
strings.TrimPrefix(site, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
err = conn.SetDeadline(time.Now().Add(waitLimit))
|
||||
if err != nil {
|
||||
t.Fatalf("set the deadline: %v", err)
|
||||
}
|
||||
|
||||
_, err = io.WriteString(conn, request)
|
||||
if err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
|
||||
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 TestAChunkedBodyStaysABodyAtDiscriminator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t))
|
||||
defer stop()
|
||||
|
||||
site, _ := startRProxy(t, "http://"+addr, startApp(t))
|
||||
|
||||
// Read as a further request, this body would ban otherClient on every site.
|
||||
const smuggledID = "smuggled-request"
|
||||
|
||||
smuggled := "GET " + bannedProbe + " HTTP/1.1\r\nHost: site.example\r\n" +
|
||||
discriminator.ClientHeader + ": " + otherClient + "\r\n" +
|
||||
"X-Request-ID: " + smuggledID + "\r\n\r\n"
|
||||
|
||||
status := sendRaw(t, site, "POST /upload HTTP/1.1\r\nHost: site.example\r\n"+
|
||||
"X-Forwarded-For: "+placed+"\r\nTransfer-Encoding: chunked\r\n\r\n"+
|
||||
strconv.FormatInt(int64(len(smuggled)), 16)+"\r\n"+smuggled+"\r\n0\r\n\r\n")
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("status %d, want the app's 200", status)
|
||||
}
|
||||
|
||||
// Read as a further request, it would act straight after the answer.
|
||||
for range 20 {
|
||||
wantStatus(t, site+"/", otherClient, http.StatusOK)
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
|
||||
line := discriminatorOut.findLine(t, func(fields map[string]any) bool {
|
||||
return fields["request_id"] == smuggledID
|
||||
})
|
||||
if line != nil {
|
||||
t.Errorf("discriminator read the body as a request: %v", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATargetThatCannotGoToDiscriminatorIsRefusedForABannedClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
addr, _, stop := startDiscriminator(t, discriminatorSettings(t))
|
||||
defer stop()
|
||||
|
||||
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
||||
|
||||
wantStatus(t, site+bannedProbe, placed, http.StatusForbidden)
|
||||
|
||||
status := sendRaw(t, site, "GET x:admin HTTP/1.1\r\nHost: site.example\r\n"+
|
||||
"X-Forwarded-For: "+placed+"\r\n\r\n")
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("status %d, want 400", status)
|
||||
}
|
||||
|
||||
line := out.line(t, "path", "admin")
|
||||
if line["disposition"] != nil || line["default_disposition"] != nil {
|
||||
t.Errorf("rproxy's line is %v, want no disposition", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const zoned = "2001:db8::1%eth0"
|
||||
|
||||
addr, _, stop := startDiscriminator(t, discriminatorSettings(t))
|
||||
defer stop()
|
||||
|
||||
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
||||
|
||||
wantStatus(t, site+bannedProbe, zoned, http.StatusForbidden)
|
||||
wantStatus(t, site+"/", zoned, http.StatusForbidden)
|
||||
wantStatus(t, site+"/", "2001:db8::1", http.StatusForbidden)
|
||||
|
||||
line := out.line(t, "path", bannedProbe)
|
||||
if line["client_ip"] != "2001:db8::1" || line["default_disposition"] != nil {
|
||||
t.Errorf("rproxy's line is %v", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStatusDiscriminatorDoesNotTakeIsReportedAs599(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const status = 799
|
||||
|
||||
app := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
t.Cleanup(app.Close)
|
||||
|
||||
addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t))
|
||||
defer stop()
|
||||
|
||||
site, out := startRProxy(t, "http://"+addr, app.URL)
|
||||
|
||||
wantStatus(t, site+"/", placed, status)
|
||||
|
||||
line := out.line(t, "path", "/")
|
||||
if line["status"] != float64(status) {
|
||||
t.Errorf("rproxy's line is %v, want the status the visitor got", line)
|
||||
}
|
||||
|
||||
id, _ := line["request_id"].(string)
|
||||
|
||||
report := discriminatorLine(t, discriminatorOut, id, "report")
|
||||
if report["status"] != 599.0 {
|
||||
t.Errorf("discriminator's line is %v, want status 599", report)
|
||||
}
|
||||
}
|
||||
|
||||
// startRProxyProcess runs `sww rproxy` in a process of its own, with the
|
||||
// settings in env, each as NAME=value. It returns where rproxy listens, the
|
||||
// process, and what rproxy writes.
|
||||
func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output) {
|
||||
t.Helper()
|
||||
|
||||
binary, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatalf("find the test binary: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
out := &output{}
|
||||
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
|
||||
cmd.Stdout = out
|
||||
|
||||
cmd.Env = append(os.Environ(),
|
||||
commandForMain+"=rproxy",
|
||||
"SWRP_LISTEN_ADDR="+localhost+":0")
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
})
|
||||
|
||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||
|
||||
return addr, cmd, out
|
||||
}
|
||||
|
||||
func TestRProxyAsksADiscriminatorThatSpeaksHTTP2OverHTTP1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
protocols := make(chan string, 10)
|
||||
backend := httptest.NewUnstartedServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(discriminator.ClientHeader) != "" {
|
||||
protocols <- r.Proto
|
||||
|
||||
w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionDeny)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
}))
|
||||
backend.EnableHTTP2 = true
|
||||
backend.StartTLS()
|
||||
t.Cleanup(backend.Close)
|
||||
|
||||
// rproxy has no setting for the certificates it trusts, so it is told of this
|
||||
// one as Go's client is told of any, in a process of its own.
|
||||
certificates := filepath.Join(t.TempDir(), "discriminator.pem")
|
||||
|
||||
err := os.WriteFile(certificates, pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE", Bytes: backend.Certificate().Raw,
|
||||
}), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write the certificate: %v", err)
|
||||
}
|
||||
|
||||
addr, _, _ := startRProxyProcess(t,
|
||||
wafBackend+"="+backend.URL,
|
||||
"SWRP_UPSTREAM_URL="+startApp(t),
|
||||
"SSL_CERT_FILE="+certificates)
|
||||
|
||||
for name, headers := range map[string]string{
|
||||
"no such header": "",
|
||||
"Upgrade": "Upgrade: anything\r\n",
|
||||
"Connection": "Connection: anything\r\n",
|
||||
"a WebSocket upgrade": "Connection: Upgrade\r\nUpgrade: websocket\r\n",
|
||||
} {
|
||||
status := sendRaw(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n")
|
||||
if status != http.StatusForbidden {
|
||||
t.Errorf("with %s: status %d, want discriminator's 403", name, status)
|
||||
}
|
||||
|
||||
select {
|
||||
case protocol := <-protocols:
|
||||
if protocol != "HTTP/1.1" {
|
||||
t.Errorf("with %s: asked over %s, want HTTP/1.1", name, protocol)
|
||||
}
|
||||
case <-time.After(waitLimit):
|
||||
t.Errorf("with %s: discriminator was not asked", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxyProcessLinesNameTheHostAndComeAtInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
t.Fatalf("the host's name: %v", err)
|
||||
}
|
||||
|
||||
// Nothing listens at the backend's address; no request asks it.
|
||||
_, out := startRProxy(t, "http://127.0.0.1:9", startApp(t))
|
||||
|
||||
line := out.line(t, "msg", "starting")
|
||||
if line["instance"] != hostname || line["level"] != "INFO" {
|
||||
t.Errorf("rproxy's line is %v, want instance %s at INFO", line, hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxyStoppingWaitsForAReportBeingSent(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) != "" {
|
||||
w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionAllow)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
close(reportGotThere)
|
||||
<-release
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(backend.Close)
|
||||
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
defer stop()
|
||||
|
||||
env := map[string]string{
|
||||
rproxyListenAddr: localhost + ":0",
|
||||
"SWRP_UPSTREAM_URL": startApp(t),
|
||||
rproxyTimeout: "60000",
|
||||
wafBackend: backend.URL,
|
||||
}
|
||||
out := &output{}
|
||||
exited := make(chan int, 1)
|
||||
|
||||
go func() {
|
||||
exited <- runRProxy(ctx, env, out)
|
||||
}()
|
||||
|
||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||
|
||||
wantStatus(t, "http://"+addr+"/", placed, http.StatusOK)
|
||||
|
||||
select {
|
||||
case <-reportGotThere:
|
||||
case <-time.After(waitLimit):
|
||||
t.Fatal("no report")
|
||||
}
|
||||
|
||||
stop()
|
||||
|
||||
select {
|
||||
case <-exited:
|
||||
t.Error("rproxy stopped with a report on its way")
|
||||
close(release)
|
||||
|
||||
return
|
||||
case <-time.After(10 * pollInterval):
|
||||
}
|
||||
|
||||
close(release)
|
||||
|
||||
select {
|
||||
case status := <-exited:
|
||||
if status != 0 {
|
||||
t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text())
|
||||
}
|
||||
case <-time.After(waitLimit):
|
||||
t.Error("rproxy still running, the report having been answered")
|
||||
}
|
||||
}
|
||||
|
||||
// sendAndKeepOpen sends request to rproxy at addr, and returns the connection. A
|
||||
// read from it ends with an error once waitLimit is up.
|
||||
func sendAndKeepOpen(t *testing.T, addr, request 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)
|
||||
}
|
||||
|
||||
_, err = io.WriteString(conn, request)
|
||||
if err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
cutOffAfter = 5 * time.Second
|
||||
upstreamSetting = "SWRP_UPSTREAM_URL"
|
||||
)
|
||||
|
||||
asked := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
app := httptest.NewServer(http.HandlerFunc(
|
||||
func(http.ResponseWriter, *http.Request) {
|
||||
close(asked)
|
||||
<-release
|
||||
}))
|
||||
t.Cleanup(app.Close)
|
||||
t.Cleanup(func() { close(release) })
|
||||
|
||||
backend := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionAllow)
|
||||
}))
|
||||
t.Cleanup(backend.Close)
|
||||
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
defer stop()
|
||||
|
||||
env := map[string]string{
|
||||
rproxyListenAddr: localhost + ":0",
|
||||
upstreamSetting: app.URL,
|
||||
wafBackend: backend.URL,
|
||||
}
|
||||
out := &output{}
|
||||
exited := make(chan int, 1)
|
||||
|
||||
go func() {
|
||||
exited <- runRProxy(ctx, env, out)
|
||||
}()
|
||||
|
||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||
conn := sendAndKeepOpen(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
||||
|
||||
select {
|
||||
case <-asked:
|
||||
case <-time.After(waitLimit):
|
||||
t.Fatal("the app was not asked")
|
||||
}
|
||||
|
||||
stopped := time.Now()
|
||||
|
||||
stop()
|
||||
|
||||
text, err := io.ReadAll(conn)
|
||||
if err != nil || len(text) != 0 {
|
||||
t.Errorf("read %q (%v), want the connection cut off", text, err)
|
||||
}
|
||||
|
||||
if elapsed := time.Since(stopped); elapsed < cutOffAfter {
|
||||
t.Errorf("cut off after %s, want %s", elapsed, cutOffAfter)
|
||||
}
|
||||
|
||||
select {
|
||||
case status := <-exited:
|
||||
if status != 0 {
|
||||
t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text())
|
||||
}
|
||||
case <-time.After(waitLimit):
|
||||
t.Error("rproxy still running after the cut-off")
|
||||
}
|
||||
|
||||
out.line(t, "msg", "requests still in progress were cut off")
|
||||
}
|
||||
|
||||
func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, setting string
|
||||
env map[string]string
|
||||
}{
|
||||
{"no backend", wafBackend, nil},
|
||||
{
|
||||
"another invalid", rproxyTimeout,
|
||||
map[string]string{wafBackend: "http://discriminator:8080", rproxyTimeout: "soon"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
out := &output{}
|
||||
|
||||
if status := runRProxy(t.Context(), tc.env, out); status != 1 {
|
||||
t.Errorf("exit status %d, want 1", status)
|
||||
}
|
||||
|
||||
line := out.line(t, "msg", "invalid setting")
|
||||
message, _ := line["error"].(string)
|
||||
|
||||
if !strings.HasPrefix(message, tc.setting+": ") {
|
||||
t.Errorf("start refused with %v, want it to name %s", line, tc.setting)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxyAddressInUseStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
taken, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = taken.Close()
|
||||
}()
|
||||
|
||||
out := &output{}
|
||||
|
||||
status := runRProxy(t.Context(), map[string]string{
|
||||
wafBackend: "http://discriminator:8080",
|
||||
rproxyListenAddr: taken.Addr().String(),
|
||||
}, out)
|
||||
if status != 1 {
|
||||
t.Errorf("exit status %d, want 1", status)
|
||||
}
|
||||
|
||||
out.line(t, "msg", "cannot listen on SWRP_LISTEN_ADDR")
|
||||
}
|
||||
|
||||
func TestMainRunsRProxy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Nothing listens there; the health check does not ask.
|
||||
addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9")
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
"http://"+addr+discriminator.HealthPath, http.NoBody)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
res, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("health check: %v", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
|
||||
if err != nil || string(body) != "ok\n" {
|
||||
t.Errorf("health check answered %q (%v)", body, err)
|
||||
}
|
||||
|
||||
err = cmd.Process.Signal(syscall.SIGTERM)
|
||||
if err != nil {
|
||||
t.Fatalf("signal: %v", err)
|
||||
}
|
||||
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
t.Errorf("exit: %v; output:\n%s", err, out.text())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user