Added access log to server and modified mock-fts to use it
This commit is contained in:
1 parent
2490519c2b
commit
02e3d910a2
10 files changed
+389
-29
No files matched your search
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/pires/go-proxyproto"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/config"
|
||||
"git.avsdev.uk/Go/job-search/internal/logging"
|
||||
"git.avsdev.uk/Go/job-search/internal/middleware"
|
||||
"git.avsdev.uk/Go/job-search/internal/util"
|
||||
"git.avsdev.uk/Go/job-search/internal/websocket"
|
||||
@@ -49,6 +51,16 @@ func WithLogger(logger *slog.Logger) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithAccessLog makes the server write a line to logger for every request, in
|
||||
// the Combined Log Format (see logging.AccessLog), as well as the request
|
||||
// lines it logs with slog. The line is written when the request ends, and
|
||||
// covers requests that middleware refuses.
|
||||
func WithAccessLog(logger *log.Logger) Option {
|
||||
return func(s *Server) {
|
||||
s.accessLog = logger
|
||||
}
|
||||
}
|
||||
|
||||
// WithMiddleware appends middleware to a group. This is the generic hook
|
||||
// (auth, metrics, tracing, custom header rules, ...).
|
||||
func WithMiddleware(group Group, middlewares ...Middleware) Option {
|
||||
@@ -82,10 +94,12 @@ type limiterHook struct {
|
||||
|
||||
// Server is the application server.
|
||||
type Server struct {
|
||||
conf *config.JSCServer
|
||||
log *slog.Logger
|
||||
hooks map[Group][]Middleware
|
||||
limiters map[Group]limiterHook
|
||||
conf *config.JSCServer
|
||||
log *slog.Logger
|
||||
// accessLog is where the access log is written, or nil for none.
|
||||
accessLog *log.Logger
|
||||
hooks map[Group][]Middleware
|
||||
limiters map[Group]limiterHook
|
||||
|
||||
// trusted are the proxies whose forwarding headers are believed.
|
||||
trusted []netip.Prefix
|
||||
@@ -156,6 +170,9 @@ func New(conf *config.JSCServer, opts ...Option) (*Server, error) {
|
||||
server.backgroundCancel()
|
||||
return nil, err
|
||||
}
|
||||
if server.accessLog != nil {
|
||||
server.handler = logging.AccessLog(server.accessLog, true, server.handler)
|
||||
}
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -218,3 +221,89 @@ func TestNoLimitIfUnset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With WithAccessLog, each request, including one refused by middleware, is
|
||||
// written to the access log in the Combined Log Format.
|
||||
func TestAccessLog(t *testing.T) {
|
||||
var buffer bytes.Buffer
|
||||
ts := newTestServer(t, func(conf *config.JSCServer) {
|
||||
conf.Headers.AllowedHosts = []string{"example.com"}
|
||||
}, WithAccessLog(log.New(&buffer, "", 0)))
|
||||
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/health", nil)
|
||||
req.Host = "example.com"
|
||||
req.Header.Set("User-Agent", "test-agent")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
// A Host that isn't allowed is refused by RequestChecks.
|
||||
res, err = http.Get(ts.URL + "/api/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(buffer.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("access log has %d lines; want 2:\n%s", len(lines), &buffer)
|
||||
}
|
||||
if !strings.Contains(lines[0], `"GET /api/health HTTP/1.1" 200`) ||
|
||||
!strings.Contains(lines[0], `"test-agent"`) {
|
||||
t.Errorf("first line = %q; want a 200 with the user agent", lines[0])
|
||||
}
|
||||
if !strings.Contains(lines[1], `"GET /api/health HTTP/1.1" 421`) {
|
||||
t.Errorf("second line = %q; want the refused request's 421", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
// With WithAccessLog, a WebSocket upgrade still hijacks the connection: the
|
||||
// client gets its welcome, and the upgrade is logged with 101.
|
||||
func TestAccessLogWebSocket(t *testing.T) {
|
||||
var buffer syncBuffer
|
||||
ts := newTestServer(t, nil, WithAccessLog(log.New(&buffer, "", 0)))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws"
|
||||
conn, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var welcome ws.Envelope
|
||||
err = wsjson.Read(ctx, conn, &welcome)
|
||||
if err != nil || welcome.Type != ws.TypeWelcome {
|
||||
t.Fatalf("want welcome, got %+v, %v", welcome, err)
|
||||
}
|
||||
conn.Close(websocket.StatusNormalClosure, "")
|
||||
|
||||
// The line is written when the connection's handler returns.
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for !strings.Contains(buffer.String(), `"GET /ws HTTP/1.1" 101`) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("upgrade not logged with 101: %q", buffer.String())
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// syncBuffer is a bytes.Buffer safe to write from one goroutine while
|
||||
// another reads it.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buffer bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(data []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buffer.Write(data)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buffer.String()
|
||||
}
|
||||
Reference in new issue
Block a user