Files
logwisp/internal/sink/httpchain/httpchain_test.go
T

254 lines
8.4 KiB
Go

package httpchain
import (
"bytes"
"context"
"crypto/tls"
"io"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/session"
ingest "github.com/lixenwraith/logwisp/internal/source/httpchain"
"github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/log"
)
// A redirect is never followed: it would resend the batch wherever the
// response points, plaintext http included.
func TestRedirectIsNotFollowed(t *testing.T) {
var leaked atomic.Bool
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { leaked.Store(true) }))
t.Cleanup(target.Close)
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusPermanentRedirect)
}))
t.Cleanup(redirector.Close)
host, port, err := net.SplitHostPort(redirector.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
p, _ := strconv.ParseInt(port, 10, 64)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{"host": host, "port": p},
log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
transient, err := created.(*HTTPChainSink).post(t.Context(), []byte("{}\n"))
if err == nil || transient {
t.Fatalf("post = (transient %v, %v), want a permanent error", transient, err)
}
if leaked.Load() {
t.Fatal("the batch followed the redirect")
}
}
// Under scram a 401 on ingest (an expired token, a reloaded source) logs in
// again, and the held batch is delivered exactly once.
func TestRefusedTokenLogsInAgainAndDeliversOnce(t *testing.T) {
pki := testutil.NewPKI(t, "edge-01")
dir := t.TempDir()
cred, err := auth.NewCredential("edge-01", "edge-01-secret")
if err != nil {
t.Fatal(err)
}
data, err := (&authz.Credentials{DecoyKey: make([]byte, 32), Users: []*auth.Credential{cred}}).Marshal()
if err != nil {
t.Fatal(err)
}
creds, password := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge-01.pass")
testutil.WriteFile(t, creds, string(data))
testutil.WriteFile(t, password, "edge-01-secret\n")
serverCert, err := tls.LoadX509KeyPair(pki.ServerCert, pki.ServerKey)
if err != nil {
t.Fatal(err)
}
serverTLS := &tls.Config{Certificates: []tls.Certificate{serverCert}}
listener, err := authz.New(&config.AuthOptions{Type: authz.MethodSCRAM, CredentialsFile: creds}, serverTLS, nil, authz.RoleChainListener, authz.HTTP)
if err != nil {
t.Fatal(err)
}
if err := listener.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(listener.Close)
var refused, delivered atomic.Int64
mux := http.NewServeMux()
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) { listener.ServeAuth(w, r) })
mux.HandleFunc("POST /ingest", func(w http.ResponseWriter, r *http.Request) {
if _, status, err := listener.AuthorizeRequest(r); err != nil {
authz.Refuse(w, status)
return
}
if refused.Add(1) == 1 {
authz.Refuse(w, http.StatusUnauthorized) // as after a reload
return
}
delivered.Add(1)
w.WriteHeader(http.StatusNoContent)
})
srv := httptest.NewUnstartedServer(mux)
srv.TLS = serverTLS
srv.StartTLS()
t.Cleanup(srv.Close)
_, port, _ := net.SplitHostPort(srv.Listener.Addr().String())
p, _ := strconv.ParseInt(port, 10, 64)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{
"host": "127.0.0.1", "port": p, "backoff_min_ms": int64(10),
"tls": map[string]any{"enabled": true, "ca_file": pki.CA},
"auth": map[string]any{"type": "scram", "username": "edge-01", "password_file": password},
}, log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
sink := created.(*HTTPChainSink)
sink.append(core.TransportEvent{Time: time.Now(), Payload: []byte("entry")})
sink.flush(t.Context())
if delivered.Load() != 1 || sink.droppedBatches.Load() != 0 {
t.Fatalf("delivered %d, dropped %d", delivered.Load(), sink.droppedBatches.Load())
}
if logins := listener.Stats()["auth_allowed"]; logins != uint64(2) {
t.Fatalf("logins = %v, want 2", logins)
}
}
// The target URL brackets an IPv6 host and escapes its zone: a URL built
// from the raw host could not even form a request.
func TestTargetURLBracketsAnIPv6Host(t *testing.T) {
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
for host, want := range map[string]string{
"::1": "http://[::1]:9000/ingest",
"fe80::1%eth0": "http://[fe80::1%25eth0]:9000/ingest",
} {
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{"host": host, "port": int64(9000)},
log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
if got := created.(*HTTPChainSink).url; got != want {
t.Errorf("host %q: target %q, want %q", host, got, want)
}
}
}
// A link runs over the IPv6 loopback with TLS: the source binds ::1, and the
// sink dials it and verifies the certificate's ::1 address.
func TestLinkOverIPv6LoopbackWithTLS(t *testing.T) {
testutil.RequireIPv6(t)
pki := testutil.NewPKI(t, "edge-01")
probe, err := net.Listen("tcp6", "[::1]:0")
if err != nil {
t.Fatal(err)
}
_, port, _ := net.SplitHostPort(probe.Addr().String())
probe.Close()
p, _ := strconv.ParseInt(port, 10, 64)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
src, err := ingest.NewHTTPChainSourcePlugin("in", map[string]any{
"host": "::1", "port": p,
"tls": map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey},
}, log.NewLogger(), session.NewProxy(manager, "in"))
if err != nil {
t.Fatal(err)
}
entries := src.Subscribe()
if err := src.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(src.Stop)
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{
"host": "::1", "port": p,
"tls": map[string]any{"enabled": true, "ca_file": pki.CA},
}, log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
sink := created.(*HTTPChainSink)
sink.append(core.TransportEvent{Time: time.Now(), Payload: []byte("over IPv6")})
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
sink.flush(ctx)
if sink.droppedBatches.Load() != 0 {
t.Fatalf("batch not delivered: %d request errors", sink.requestErrors.Load())
}
select {
case e := <-entries:
if e.Message != "over IPv6" {
t.Fatalf("message %q", e.Message)
}
case <-time.After(2 * time.Second):
t.Fatal("the source published nothing")
}
}
// Stop delivers what is queued and batched, so the end of a finite input
// reaches the source whole, and returns at the flush bound when the source
// stops answering.
func TestStopDeliversTheQueueWithinTheBound(t *testing.T) {
const n = 250 // three batches of the default 100
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
// stop queues n entries, starts and stops a sink, and returns how long
// Stop took and how many entries an answering source received
stop := func(answering bool) (time.Duration, int64) {
var received atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if !answering {
<-r.Context().Done() // the sink gave up
return
}
received.Add(int64(bytes.Count(body, []byte("\n"))))
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(srv.Close)
_, port, _ := net.SplitHostPort(srv.Listener.Addr().String())
p, _ := strconv.ParseInt(port, 10, 64)
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{"host": "127.0.0.1", "port": p},
log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
sink := created.(*HTTPChainSink)
for range n {
sink.Input() <- core.TransportEvent{Time: time.Now(), Payload: []byte("entry")}
}
if err := sink.Start(t.Context()); err != nil {
t.Fatal(err)
}
start := time.Now()
sink.Stop()
return time.Since(start), received.Load()
}
if _, got := stop(true); got != n {
t.Fatalf("the source received %d of %d entries", got, n)
}
if d, _ := stop(false); d > 3500*time.Millisecond {
t.Fatalf("Stop took %v past a 2 s flush bound", d)
}
}