diff --git a/CODE_CONVENTIONS.md b/CODE_CONVENTIONS.md index 9858de8..7c23936 100644 --- a/CODE_CONVENTIONS.md +++ b/CODE_CONVENTIONS.md @@ -754,14 +754,20 @@ says what 0 or empty means, and the code treats it consistently: - A timeout of 0 or less means **no limit**, and an interval of 0 or less means **off** (e.g. `ping_interval`), never "already expired". Where off - makes no sense the setting rejects 0 instead (a source's `interval`: a - source is turned off with `enabled`). Code applies a timeout with a helper - rather than `context.WithTimeout(ctx, 0)`: + makes no sense, or no limit is unsafe, the setting rejects 0 instead (a + source's `interval`: a source is turned off with `enabled`; + `server.timeouts.read_header`: a client could hold a connection open for + ever). Code applies a timeout with a helper rather than + `context.WithTimeout(ctx, 0)`: ```go writeCtx, cancel := util.WithTimeout(ctx, conf.WriteTimeout) // no deadline if 0 or less ``` + Where a library reads 0 as some other timeout (`http.Server`'s + `IdleTimeout` uses `ReadTimeout`), the code passes it a negative timeout + instead, which such libraries read as no limit. + - A size of 0 means **the default** (`max_message_size` of `0B` reads 32KiB), and -1 means **no limit** (`util.ByteSizeNoLimit`); no other size may be negative. A setting for which no limit is unsafe rejects -1 in its diff --git a/cmd/jobsearch/main.go b/cmd/jobsearch/main.go index 7dfb88b..36fd8e5 100644 --- a/cmd/jobsearch/main.go +++ b/cmd/jobsearch/main.go @@ -29,8 +29,9 @@ import ( "git.avsdev.uk/Go/job-search/internal/cli/config" "git.avsdev.uk/Go/job-search/internal/cli/fetch" "git.avsdev.uk/Go/job-search/internal/cli/migrate" + "git.avsdev.uk/Go/job-search/internal/cli/serve" "git.avsdev.uk/Go/job-search/internal/cli/version" - "git.avsdev.uk/Go/job-search/internal/cli/wsserve" + "git.avsdev.uk/Go/job-search/internal/cli/ws" ) // newRootCmd builds the command tree. It returns a fresh tree on each call so @@ -76,7 +77,8 @@ opportunities. config.NewCommand(opts), fetch.NewCommand(opts), migrate.NewCommand(opts), - wsserve.NewCommand(opts), + ws.NewCommand(opts), + serve.NewCommand(opts), ) return root diff --git a/cmd/jobsearch/main_test.go b/cmd/jobsearch/main_test.go index b0c29a2..f763dd7 100644 --- a/cmd/jobsearch/main_test.go +++ b/cmd/jobsearch/main_test.go @@ -18,7 +18,7 @@ func TestCommandTree(t *testing.T) { names = append(names, c.Name()) } - commands := []string{"config", "fetch", "migrate", "version", "wsserve"} + commands := []string{"config", "fetch", "migrate", "version", "ws"} for _, want := range commands { if !slices.Contains(names, want) { t.Errorf("job-search has no %q command; has %v", want, names) diff --git a/internal/cli/fetch/command.go b/internal/cli/fetch/command.go index 0e0cccf..6b801cb 100644 --- a/internal/cli/fetch/command.go +++ b/internal/cli/fetch/command.go @@ -21,6 +21,7 @@ func NewCommand(opts *cli.Options) *cobra.Command { slog.Debug("command persistent post-run", "command", "fetch") }, } + cmd.AddCommand(commandOnce(opts)) cmd.AddCommand(commandService(opts)) diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go new file mode 100644 index 0000000..4eaf5d5 --- /dev/null +++ b/internal/cli/serve/command.go @@ -0,0 +1,105 @@ +package serve + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + + "github.com/spf13/cobra" + + "git.avsdev.uk/Go/job-search/internal/cli" + "git.avsdev.uk/Go/job-search/internal/server" +) + +// NewCommand returns the serve command, which serves the frontend, the REST +// API and the WebSocket endpoint. +func NewCommand(opts *cli.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "serve", + Short: "Serve the frontend (/), REST API (/api) and WebSocket (/ws)", + Long: `Serve the embedded Vite frontend, the REST API under /api and the +WebSocket endpoint under /ws.`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + slog.Debug("command persistent pre-run", "command", "serve") + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + slog.Debug("command persistent post-run", "command", "serve") + }, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + slog.Debug("command run", "command", "serve") + + addrOverride, _ := cmd.Flags().GetString("listen") + hostOverride, _ := cmd.Flags().GetString("host") + portOverride, _ := cmd.Flags().GetInt("port") + foreground, _ := cmd.Flags().GetBool("foreground") + + if addrOverride != "" { + listenParts := strings.Split(addrOverride, ":") + if len(listenParts) != 2 { + return errors.New( + "--listen should be in the format of 127.0.0.1:9393", + ) + } + port, err := strconv.Atoi(listenParts[1]) + if err != nil { + return fmt.Errorf("failed to parse --listen port: %w", err) + } + hostOverride = listenParts[0] + portOverride = port + } + + conf, err := opts.RunSetup(cmd, true, foreground) + if err != nil { + return fmt.Errorf("load configuration: %w", err) + } + + if portOverride != 0 { + conf.Server.Port = portOverride + } + if hostOverride != "" { + conf.Server.Host = hostOverride + } + + // ctx is cancelled on the first SIGINT (Ctrl+C) or SIGTERM. + ctx, stop := signal.NotifyContext( + context.Background(), os.Interrupt, syscall.SIGTERM, + ) + defer stop() + + // After the first signal, restore default signal handling so a second + // Ctrl+C force-kills the process if graceful shutdown hangs. + go func() { + <-ctx.Done() + stop() + }() + + appServer, err := server.New(&conf.Server) + if err != nil { + return err + } + + return appServer.Run(ctx) + }, + } + + flags := cmd.Flags() + flags.String( + "listen", "", "listen address (default from config, 127.0.0.1:9393)", + ) + flags.String( + "host", "", "listen address (default from config, 127.0.0.1)", + ) + flags.Int("port", 0, "listen address (default from config, 9393)") + flags.Bool("foreground", false, "run in the foreground") + cmd.MarkFlagsMutuallyExclusive("listen", "host") + cmd.MarkFlagsMutuallyExclusive("listen", "port") + + return cmd +} diff --git a/internal/cli/ws/command.go b/internal/cli/ws/command.go new file mode 100644 index 0000000..386d438 --- /dev/null +++ b/internal/cli/ws/command.go @@ -0,0 +1,30 @@ +// Package ws holds the "ws" command: "ws serve" runs the plain TCP server for +// websocket hub clients, and "ws dial" tests the HTTP server's /ws endpoint. +package ws + +import ( + "log/slog" + + "github.com/spf13/cobra" + + "git.avsdev.uk/Go/job-search/internal/cli" +) + +// NewCommand returns the "ws" command, for the WebSocket servers. +func NewCommand(opts *cli.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "ws", + Short: "WebSocket servers and tools", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + slog.Debug("command persistent pre-run", "command", "ws") + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + slog.Debug("command persistent post-run", "command", "ws") + }, + } + + cmd.AddCommand(commandServe(opts)) + cmd.AddCommand(commandDial(opts)) + + return cmd +} diff --git a/internal/cli/ws/dial.go b/internal/cli/ws/dial.go new file mode 100644 index 0000000..49257e4 --- /dev/null +++ b/internal/cli/ws/dial.go @@ -0,0 +1,416 @@ +package ws + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/coder/websocket" + "github.com/spf13/cobra" + + "git.avsdev.uk/Go/job-search/internal/cli" + "git.avsdev.uk/Go/job-search/internal/config" + "git.avsdev.uk/Go/job-search/internal/util" + hub "git.avsdev.uk/Go/job-search/internal/websocket" +) + +// pingID is the ID of the ping --ping sends, which the pong it waits for +// carries back. +const pingID = "ping" + +// dialSettings are the dial command's flag values. +type dialSettings struct { + url string + origin string + messages []string + ping bool + listen bool + timeout time.Duration +} + +// commandDial returns the "ws dial" command, which opens a WebSocket on the +// HTTP server's /ws path, sends messages and prints the replies. +func commandDial(opts *cli.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "dial", + Short: "Open a WebSocket on /ws, send messages and print the replies", + Long: `Upgrade an HTTP connection to a WebSocket on the server's /ws path and print +every message the server sends, starting with its welcome. + +With --ping, it sends a ping and waits for the pong, failing if none arrives +within --timeout. + +With --message, each one is sent in turn and the reply to it printed. + +With --ping or --message, dial then closes the connection, or with --listen +prints every message the server pushes until the connection closes or Ctrl+C +is pressed. + +With neither, each line read from stdin is sent as a message (blank lines are +skipped), until Ctrl+C is pressed or the server closes the connection. The end +of stdin stops the sending, but the connection stays open. + +The URL defaults to the configured server.host and server.port (wss:// if +server.tls.enabled is set). Set --origin to test the Origin check: an upgrade +from an origin that isn't the server's own or one of +server.csrf.cross_origin_protection.trusted_origins is refused with 403.`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + slog.Debug("command persistent pre-run", "command", "ws.dial") + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + slog.Debug("command persistent post-run", "command", "ws.dial") + }, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + slog.Debug("command run", "command", "ws.dial") + + settings, err := dialFlags(cmd) + if err != nil { + return err + } + + if settings.url == "" { + conf, err := opts.RunSetup(cmd, true, true) + if err != nil { + return fmt.Errorf("load configuration: %w", err) + } + settings.url = defaultURL(conf.Server) + } + + // ctx is cancelled on the first SIGINT (Ctrl+C) or SIGTERM. + ctx, stop := signal.NotifyContext( + context.Background(), os.Interrupt, syscall.SIGTERM, + ) + defer stop() + + return dial(ctx, cmd.InOrStdin(), cmd.OutOrStdout(), settings) + }, + } + + flags := cmd.Flags() + flags.String( + "url", + "", + "WebSocket URL (default from config, ws://127.0.0.1:9393/ws)", + ) + flags.String( + "origin", + "", + "Origin header to send, as a browser would (default none)", + ) + flags.StringArray( + "message", + nil, + "message to send, as JSON; repeat to send several (default: stdin)", + ) + flags.Bool( + "ping", + false, + "send a ping and wait for the pong (before any --message)", + ) + flags.Bool( + "listen", + false, + "with --ping or --message, keep printing pushed messages until the "+ + "connection closes or Ctrl+C", + ) + flags.Duration( + "timeout", + 10*time.Second, + "how long to wait for the upgrade and for each reply; 0s for no limit", + ) + + return cmd +} + +// dialFlags reads the dial command's flags. The flags are all defined by +// commandDial, so getting them can't fail. +func dialFlags(cmd *cobra.Command) (dialSettings, error) { + flags := cmd.Flags() + url, urlErr := flags.GetString("url") + origin, originErr := flags.GetString("origin") + messages, messagesErr := flags.GetStringArray("message") + ping, pingErr := flags.GetBool("ping") + listen, listenErr := flags.GetBool("listen") + timeout, timeoutErr := flags.GetDuration("timeout") + + err := errors.Join( + urlErr, + originErr, + messagesErr, + pingErr, + listenErr, + timeoutErr, + ) + if err != nil { + return dialSettings{}, err + } + + return dialSettings{ + url: url, + origin: origin, + messages: messages, + ping: ping, + listen: listen, + timeout: timeout, + }, nil +} + +// defaultURL is the /ws URL of the server conf configures: ws://, or wss:// +// with TLS, on its host and port. An empty host (every interface) is dialled +// as 127.0.0.1. +func defaultURL(conf config.JSCServer) string { + scheme := "ws" + if conf.TLS.Enabled { + scheme = "wss" + } + + host := conf.Host + if host == "" { + host = "127.0.0.1" + } + + address := net.JoinHostPort(host, strconv.Itoa(conf.Port)) + + return scheme + "://" + address + "/ws" +} + +// dial opens a WebSocket at settings.url and prints the messages received +// to out, each after "<". It sends each of settings.messages, printed after +// ">", or without them each line of input (see interact), which isn't +// printed again. +func dial( + ctx context.Context, + input io.Reader, + out io.Writer, + settings dialSettings, +) error { + header := http.Header{} + if settings.origin != "" { + header.Set("Origin", settings.origin) + } + + dialCtx, cancel := util.WithTimeout(ctx, settings.timeout) + defer cancel() + + conn, res, err := websocket.Dial( + dialCtx, + settings.url, + &websocket.DialOptions{ + HTTPHeader: header, + }, + ) + if err != nil { + if res != nil { + return fmt.Errorf("upgrade refused: %s", res.Status) + } + return fmt.Errorf("dialling %s: %w", settings.url, err) + } + defer conn.CloseNow() //nolint:errcheck // finished with; it may already be closed + + fmt.Fprintf(out, "connected (%s)\n", res.Status) //nolint:errcheck // printing only + + if !settings.ping && len(settings.messages) == 0 { + return interact(ctx, input, out, conn, settings.timeout) + } + + // The server sends a welcome first. + err = receive(ctx, out, conn, settings.timeout) + if err != nil { + return err + } + + if settings.ping { + err = pingPong(ctx, out, conn, settings.timeout) + if err != nil { + return err + } + } + + for _, message := range settings.messages { + fmt.Fprintf(out, "> %s\n", message) //nolint:errcheck // printing only + + err = send(ctx, conn, message, settings.timeout) + if err != nil { + return err + } + + err = receive(ctx, out, conn, settings.timeout) + if err != nil { + return err + } + } + + if settings.listen { + return listen(ctx, out, conn) + } + + return conn.Close(websocket.StatusNormalClosure, "") +} + +// pingPong sends a ping on conn and waits at most timeout for its pong, +// printing each message received meanwhile to out. +func pingPong( + ctx context.Context, + out io.Writer, + conn *websocket.Conn, + timeout time.Duration, +) error { + ping, err := json.Marshal(hub.Envelope{ + Type: hub.TypePing, + ID: pingID, + }) + if err != nil { + return fmt.Errorf("encoding the ping: %w", err) + } + + fmt.Fprintf(out, "> %s\n", ping) //nolint:errcheck // printing only + err = send(ctx, conn, string(ping), timeout) + if err != nil { + return err + } + + waitCtx, cancel := util.WithTimeout(ctx, timeout) + defer cancel() + + for { + _, data, readErr := conn.Read(waitCtx) + if readErr != nil { + if ctx.Err() == nil && waitCtx.Err() != nil { + return fmt.Errorf("no pong within %s", timeout) + } + return fmt.Errorf("waiting for the pong: %w", readErr) + } + fmt.Fprintf(out, "< %s\n", data) //nolint:errcheck // printing only + + // Anything that isn't the pong, such as a push, is only printed. + var reply hub.Envelope + if json.Unmarshal(data, &reply) == nil && + reply.Type == hub.TypePong && reply.ID == pingID { + return nil + } + } +} + +// interact prints each message received on conn to out, and sends each line +// of input as a message, skipping blank lines, until ctx is cancelled +// (Ctrl+C) or the server closes the connection. The end of input stops the +// sending; the connection stays open. +func interact( + ctx context.Context, + input io.Reader, + out io.Writer, + conn *websocket.Conn, + timeout time.Duration, +) error { + listening := make(chan error, 1) + go func() { + listening <- listen(ctx, out, conn) + }() + + // Reading input can't be interrupted, so it has a goroutine of its own, + // left blocked if ctx is cancelled first. + lines := make(chan string) + go func() { + defer close(lines) + scanner := bufio.NewScanner(input) + for scanner.Scan() { + lines <- scanner.Text() + } + }() + + for { + select { + case <-ctx.Done(): + return <-listening + case err := <-listening: + return err + case line, ok := <-lines: + if !ok { + // The end of input: stop sending, keep listening. + lines = nil + continue + } + if strings.TrimSpace(line) == "" { + continue + } + + err := send(ctx, conn, line, timeout) + if err != nil { + return err + } + } + } +} + +// send writes message to conn as a text frame, waiting at most timeout. +func send( + ctx context.Context, + conn *websocket.Conn, + message string, + timeout time.Duration, +) error { + writeCtx, cancel := util.WithTimeout(ctx, timeout) + defer cancel() + + err := conn.Write(writeCtx, websocket.MessageText, []byte(message)) + if err != nil { + return fmt.Errorf("sending: %w", err) + } + + return nil +} + +// receive reads one message from conn, waiting at most timeout, and prints +// it to out. +func receive( + ctx context.Context, + out io.Writer, + conn *websocket.Conn, + timeout time.Duration, +) error { + readCtx, cancel := util.WithTimeout(ctx, timeout) + defer cancel() + + _, data, err := conn.Read(readCtx) + if err != nil { + return fmt.Errorf("waiting for a reply: %w", err) + } + fmt.Fprintf(out, "< %s\n", data) //nolint:errcheck // printing only + + return nil +} + +// listen prints each message received on conn to out until ctx is cancelled +// (Ctrl+C) or the server closes the connection, either of which is a normal +// end. +func listen(ctx context.Context, out io.Writer, conn *websocket.Conn) error { + for { + _, data, err := conn.Read(ctx) + status := websocket.CloseStatus(err) + switch { + case err == nil: + fmt.Fprintf(out, "< %s\n", data) //nolint:errcheck // printing only + case ctx.Err() != nil: + // Ctrl+C: Read has closed the connection already. + return nil //nolint:nilerr // a normal end + case status != -1: + fmt.Fprintf(out, "closed by the server (%d)\n", status) //nolint:errcheck // printing only + return nil + default: + return fmt.Errorf("reading: %w", err) + } + } +} diff --git a/internal/cli/ws/dial_test.go b/internal/cli/ws/dial_test.go new file mode 100644 index 0000000..364533c --- /dev/null +++ b/internal/cli/ws/dial_test.go @@ -0,0 +1,339 @@ +package ws + +import ( + "bytes" + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + coder "github.com/coder/websocket" + "github.com/spf13/cobra" + + "git.avsdev.uk/Go/job-search/internal/cli" + "git.avsdev.uk/Go/job-search/internal/cli/cmdtest" + "git.avsdev.uk/Go/job-search/internal/config" + "git.avsdev.uk/Go/job-search/internal/websocket" +) + +func newRoot() *cobra.Command { + opts := &cli.Options{} + return cmdtest.NewRoot(opts, NewCommand(opts)) +} + +// wsServer serves a hub that trusts https://admin.example.com on a test HTTP +// server, and returns the hub and its ws:// URL. +func wsServer(t *testing.T) (*websocket.Hub, string) { + t.Helper() + hub := websocket.NewHub( + config.JSCServerWebSocket{ + WriteTimeout: time.Second, + }, + websocket.WithLogger(slog.New(slog.DiscardHandler)), + websocket.WithTrustedOrigins([]string{"https://admin.example.com"}), + ) + server := httptest.NewServer(hub) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + hub.Shutdown(ctx) + server.Close() + }) + + return hub, "ws" + strings.TrimPrefix(server.URL, "http") +} + +// ping is a message the server answers with a pong. +const ping = `{"type":"ping","id":"1"}` + +// With --message, dial prints the welcome, sends the message and prints the +// reply. +func TestDialPing(t *testing.T) { + cmdtest.Setup(t, "") + _, url := wsServer(t) + + out := cmdtest.MustRun( + t, newRoot, "ws", "dial", "--url", url, "--message", ping, + ) + cmdtest.Contains( + t, + out, + "connected (101 Switching Protocols)", + `< {"type":"welcome"`, + `> {"type":"ping","id":"1"}`, + `< {"type":"pong","id":"1"`, + ) +} + +// --ping sends a ping and waits for its pong, then closes the connection. +func TestDialPingFlag(t *testing.T) { + cmdtest.Setup(t, "") + _, url := wsServer(t) + + out := cmdtest.MustRun(t, newRoot, "ws", "dial", "--url", url, "--ping") + cmdtest.Contains( + t, + out, + `> {"type":"ping","id":"ping"}`, + `< {"type":"pong","id":"ping"`, + ) +} + +// --ping fails if no pong arrives within --timeout. +func TestDialPingTimeout(t *testing.T) { + cmdtest.Setup(t, "") + // A server that sends a welcome, then never replies. + server := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + conn, err := coder.Accept(w, r, nil) + if err != nil { + return + } + defer conn.CloseNow() + conn.Write(r.Context(), coder.MessageText, []byte(`{"type":"welcome"}`)) + for { + if _, _, err := conn.Read(r.Context()); err != nil { + return + } + } + }, + )) + t.Cleanup(server.Close) + url := "ws" + strings.TrimPrefix(server.URL, "http") + + _, err := cmdtest.Run( + t, + newRoot, + "", + "ws", "dial", "--url", url, "--ping", "--timeout", "100ms", + ) + cmdtest.ErrContains(t, err, "no pong within 100ms") +} + +// Each --message is sent in turn, and the reply to each printed. +func TestDialMessages(t *testing.T) { + cmdtest.Setup(t, "") + _, url := wsServer(t) + + out := cmdtest.MustRun( + t, + newRoot, + "ws", "dial", "--url", url, + "--message", `{"type":"echo","id":"7","payload":{"a":1}}`, + "--message", `{"type":"nonsense","id":"8"}`, + ) + cmdtest.Contains( + t, + out, + `< {"type":"echo","id":"7","payload":{"a":1}}`, + `< {"type":"error","id":"8"`, + ) +} + +// An upgrade from a foreign origin is refused; a trusted one is accepted. +func TestDialOrigin(t *testing.T) { + cmdtest.Setup(t, "") + _, url := wsServer(t) + + _, err := cmdtest.Run( + t, + newRoot, + "", + "ws", "dial", "--url", url, "--message", ping, + "--origin", "https://evil.example", + ) + cmdtest.ErrContains(t, err, "upgrade refused: 403 Forbidden") + + out := cmdtest.MustRun( + t, + newRoot, + "ws", "dial", "--url", url, "--message", ping, + "--origin", "https://admin.example.com", + ) + cmdtest.Contains(t, out, "connected (101 Switching Protocols)") +} + +// With --listen, pushed messages are printed until the server closes the +// connection. +func TestDialListen(t *testing.T) { + cmdtest.Setup(t, "") + hub, url := wsServer(t) + + go func() { + // Wait for the client to connect and get its pong. + deadline := time.Now().Add(2 * time.Second) + for hub.Count() == 0 && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + time.Sleep(50 * time.Millisecond) + hub.Push("event", map[string]any{"n": 1}) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + hub.Shutdown(ctx) + }() + + out := cmdtest.MustRun( + t, newRoot, "ws", "dial", "--url", url, "--message", ping, "--listen", + ) + cmdtest.Contains( + t, + out, + `< {"type":"event","payload":{"n":1}}`, + "closed by the server (1001)", + ) +} + +// 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() +} + +// waitFor fails the test unless out comes to contain every string in want +// within a second. +func waitFor(t *testing.T, out *syncBuffer, want ...string) { + t.Helper() + deadline := time.Now().Add(time.Second) + for { + missing := "" + for _, wanted := range want { + if !strings.Contains(out.String(), wanted) { + missing = wanted + break + } + } + if missing == "" { + return + } + if time.Now().After(deadline) { + t.Fatalf("output does not contain %q:\n%s", missing, out) + } + time.Sleep(5 * time.Millisecond) + } +} + +// Without --message, each line of stdin is sent, skipping blank ones. The end +// of stdin doesn't end dial: pushes are still printed until Ctrl+C. +func TestDialStdin(t *testing.T) { + hub, url := wsServer(t) + stdin, input := io.Pipe() + out := &syncBuffer{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- dial(ctx, stdin, out, dialSettings{ + url: url, + timeout: time.Second, + }) + }() + + io.WriteString(input, ping+"\n\n"+`{"type":"echo","id":"2"}`+"\n") + input.Close() + waitFor( + t, + out, + `< {"type":"welcome"`, + `< {"type":"pong","id":"1"`, + `< {"type":"echo","id":"2"}`, + ) + if strings.Contains(out.String(), `"type":"error"`) { + t.Errorf("a blank line was sent:\n%s", out) + } + + hub.Push("event", map[string]any{"n": 1}) + waitFor(t, out, `< {"type":"event","payload":{"n":1}}`) + + cancel() // Ctrl+C + select { + case err := <-done: + if err != nil { + t.Fatalf("dial = %v; want nil after Ctrl+C", err) + } + case <-time.After(time.Second): + t.Fatal("dial did not return after Ctrl+C") + } +} + +// Without --message, dial ends when the server closes the connection. +func TestDialStdinServerClose(t *testing.T) { + hub, url := wsServer(t) + stdin, _ := io.Pipe() // never written: stdin stays open + out := &syncBuffer{} + + done := make(chan error, 1) + go func() { + done <- dial(context.Background(), stdin, out, dialSettings{ + url: url, + timeout: time.Second, + }) + }() + + waitFor(t, out, `< {"type":"welcome"`) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + hub.Shutdown(ctx) + + select { + case err := <-done: + if err != nil { + t.Fatalf("dial = %v; want nil when the server closes", err) + } + case <-time.After(time.Second): + t.Fatal("dial did not return when the server closed") + } + waitFor(t, out, "closed by the server (1001)") +} + +// Without --url, dial uses the configured host and port. +func TestDefaultURL(t *testing.T) { + tests := []struct { + name string + conf config.JSCServer + want string + }{ + {"configured", config.JSCServer{ + Host: "10.0.0.1", + Port: 8080, + }, "ws://10.0.0.1:8080/ws"}, + {"every interface", config.JSCServer{ + Port: 9393, + }, "ws://127.0.0.1:9393/ws"}, + {"IPv6", config.JSCServer{ + Host: "::1", + Port: 9393, + }, "ws://[::1]:9393/ws"}, + {"TLS", config.JSCServer{ + Host: "h", + Port: 443, + TLS: config.JSCServerTLS{ + Enabled: true, + }, + }, "wss://h:443/ws"}, + } + for _, tt := range tests { + if got := defaultURL(tt.conf); got != tt.want { + t.Errorf("%s: defaultURL = %q; want %q", tt.name, got, tt.want) + } + } +} diff --git a/internal/cli/wsserve/command.go b/internal/cli/ws/serve.go similarity index 75% rename from internal/cli/wsserve/command.go rename to internal/cli/ws/serve.go index f5c446f..af2d32d 100644 --- a/internal/cli/wsserve/command.go +++ b/internal/cli/ws/serve.go @@ -1,4 +1,4 @@ -package wsserve +package ws import ( "context" @@ -18,24 +18,25 @@ import ( "git.avsdev.uk/Go/job-search/internal/websocket/tcpserver" ) -// NewCommand returns the wsserve command, which runs a plain TCP server for -// websocket hub clients (see tcpserver). -func NewCommand(opts *cli.Options) *cobra.Command { +// commandServe returns the "ws serve" command, which runs a plain TCP server +// for websocket hub clients (see tcpserver). +func commandServe(opts *cli.Options) *cobra.Command { cmd := &cobra.Command{ - Use: "wsserve", + Use: "serve", Short: "Run JobSearch TCP Websocket server", PersistentPreRun: func(cmd *cobra.Command, args []string) { - slog.Debug("command persistent pre-run", "command", "wsserve") + slog.Debug("command persistent pre-run", "command", "ws.serve") }, PersistentPostRun: func(cmd *cobra.Command, args []string) { - slog.Debug("command persistent post-run", "command", "wsserve") + slog.Debug("command persistent post-run", "command", "ws.serve") }, RunE: func(cmd *cobra.Command, args []string) error { - slog.Debug("command run", "command", "wsserve") + slog.Debug("command run", "command", "ws.serve") addrOverride, _ := cmd.Flags().GetString("listen") hostOverride, _ := cmd.Flags().GetString("host") portOverride, _ := cmd.Flags().GetInt("port") + foreground, _ := cmd.Flags().GetBool("foreground") if addrOverride != "" { listenParts := strings.Split(addrOverride, ":") @@ -52,7 +53,7 @@ func NewCommand(opts *cli.Options) *cobra.Command { portOverride = port } - conf, err := opts.RunSetup(cmd, true, true) + conf, err := opts.RunSetup(cmd, true, foreground) if err != nil { return fmt.Errorf("load configuration: %w", err) } @@ -83,14 +84,16 @@ func NewCommand(opts *cli.Options) *cobra.Command { return server.Run(ctx) }, } - cmd.Flags().String( + + flags := cmd.Flags() + flags.String( "listen", "", "listen address (default from config, 127.0.0.1:9393)", ) - cmd.Flags().String( + flags.String( "host", "", "listen address (default from config, 127.0.0.1)", ) - cmd.Flags().Int("port", 0, "listen address (default from config, 9393)") - cmd.Flags().Bool("foreground", false, "run in the foreground") + flags.Int("port", 0, "listen address (default from config, 9393)") + flags.Bool("foreground", false, "run in the foreground") cmd.MarkFlagsMutuallyExclusive("listen", "host") cmd.MarkFlagsMutuallyExclusive("listen", "port") diff --git a/internal/config/README.md b/internal/config/README.md index 3297663..938317e 100644 --- a/internal/config/README.md +++ b/internal/config/README.md @@ -23,47 +23,195 @@ environment variables, merged through [Viper](https://github.com/spf13/viper). previous value is kept. When more than one place sets a key, environment variables win over the -config file, which wins over the defaults. A `debug` or `error` log level -already set on `conf` before `Init` (from the `--debug` and `--quiet` flags) -is kept. +config file, which wins over the defaults. -## Keys +## Configuration Keys + +Configuration is managed in sections: + +| Key | Section description | +|---|---| +| `logging` | Paths to log files and log level control | +| `database` | Database connection/file details | +| `sources` | Opportunity source settings | +| `server` | Server configuration including CSRF protection, Websockets, Proxy handling and rate limits | + +### Logging | Key | Type | Default | Notes | |---|---|---|---| | `logging.level` | string | `warn` | One of `debug`, `info`, `warn`, `error` | -| `logging.file` | string | | Log file for `fetch service` (unless `--foreground`); other commands log to the terminal. Empty: log to the terminal | -| `database.file` | string | | Path to the SQLite database file, used by the `migrate` commands. Empty: commands that need the database fail | +| `logging.file` | string | | Log file (JSON lines) for long-running commands such as `fetch service`. Empty: log to the terminal | + +Logging always starts on the terminal. Long-running commands switch to the file +in `logging.file` once the configuration is loaded, unless it is empty (they +then stay on the terminal, with a warning) or `--foreground` is passed. +All other commands log to the terminal. + +The `--debug` and `--quiet` flags force the log level to `debug` and `error` +respectively, ignoring the `logging.level` configuration. `--debug` takes +priority over `--quiet`. + +### Database + +| Key | Type | Default | Notes | +|---|---|---|---| +| `database.file` | string | | Path to the SQLite database file. Empty: commands that need the database fail | + +Database is currently restricted to SQLite v3. At this point, no other engine is +supported. + + +### Opportunity Sources + +| Key | Type | Default | Notes | +|---|---|---|---| | `sources..enabled` | bool | `false` | For every source | | `sources..interval` | duration | `10m` | For every source; e.g. `30s`, `1h`. Must be greater than 0; turn a source off with `enabled` | | `sources.find_a_tender.api_root` | string | `https://www.find-tender.service.gov.uk/api/1.0` | URL of the Find a Tender API. Empty: the source fails to start if enabled, and `fetch once` fails | | `sources.fts_mock.api_root` | string | | URL of the mock FTS server (see `internal/mock`). Empty: the source fails to start if enabled | | `sources.sleeper.block_shutdown` | bool | `false` | Test source: if true, its sleep ignores shutdown | -| `server.host` | string | `127.0.0.1` | Address to listen on; empty: every interface | -| `server.port` | int | `9393` | Port to listen on; `0`: any free port | -| `server.timeouts.shutdown` | duration | `15s` | How long shutdown waits for clients to close; `0s`: until they have | -| `server.rate_limits.idle_ttl` | duration | `10m` | How long a client's rate limiter is kept after its last request; `0s`: for ever | -| `server.websocket.enable_compression` | bool | `false` | permessage-deflate | -| `server.websocket.max_message_size` | byte size | `32KiB` | Largest message read from a client; `0B` for 32KiB. `-1` (no limit) is rejected, as a client could then exhaust the server's memory | -| `server.websocket.connection_limit.*` | | | A rate limit rule for new connections from each client IP, WebSocket and plain TCP alike; limiters are kept for `server.rate_limits.idle_ttl` | -| `server.websocket.message_rate_limit.*` | | | A rate limit rule for messages from each connection | -| `server.websocket.*.enabled` | bool | `false` | In a rate limit rule: whether it applies | -| `server.websocket.*.per_second` | int | `50` | In a rate limit rule: sustained rate. `0` or less: the rule is off | -| `server.websocket.*.burst` | int | `100` | In a rate limit rule: largest burst. Below `1`: 1 | -| `server.websocket.write_timeout` | duration | `10s` | `0s` for none | -| `server.websocket.ping_interval` | duration | `30s` | How long a connection may be idle before it is pinged; `0s`: no pings | -| `server.websocket.pong_timeout` | duration | `10s` | `0s` for none | The current sources are `find_a_tender`, `fts_mock` and `sleeper`. See "Addition of a new opportunities source" in the top level README for how to add a new source. +### Server + +The `server` section is split into a number of sub-sections: + +| Key | Section description | +|---|---| +| `server` | Top level server configuration settings | +| `server.timeouts` | HTTP read, write and idle timeouts, and the shutdown timeout | +| `server.rate_limits` | Global (every request) and API rate limits. Also see `server.websocket.connection_limit` for WebSocket and plain TCP connection limits | +| `server.proxy` | PROXY protocol and proxy header settings | +| `server.csrf` | CSRF and cross-origin protection settings | +| `server.tls` | TLS certificate, minimum version and HTTP redirect | +| `server.headers` | HTTP header settings including policy headers | +| `server.frontend` | How the frontend is served (not used yet) | +| `server.websocket` | Websocket configuration | + +#### Server + +| Key | Type | Default | Notes | +|---|---|---|---| +| `server.host` | string | `127.0.0.1` | Address to listen on; empty: every interface | +| `server.port` | int | `9393` | Port to listen on; `0`: any free port | +| `server.max_header_bytes` | byte size | `8KB` | Largest request header block; `0B` for 8KB. `-1` (no limit) is rejected, as the HTTP server always has one | +| `server.max_body_bytes` | byte size | `1MB` | Largest request body; `0B` for 1MB, `-1` for no limit | + +#### Timeouts + +| Key | Type | Default | Notes | +|---|---|---|---| +| `server.timeouts.read_header` | duration | `5s` | Reading a request's headers. Must be greater than 0, as a client could otherwise hold a connection open for ever | +| `server.timeouts.read` | duration | `15s` | Reading a whole request; `0s` for none | +| `server.timeouts.write` | duration | `30s` | Writing a response; `0s` for none | +| `server.timeouts.idle` | duration | `2m` | Waiting for the next request on a keep-alive connection; `0s` for none | +| `server.timeouts.shutdown` | duration | `15s` | How long shutdown waits for open requests; `0s`: until they finish | + +#### Rate Limits + +| Key | Type | Default | Notes | +|---|---|---|---| +| `server.rate_limits..enabled` | bool | `false` | `` is `global` or `api` (see rate limit rules below) | +| `server.rate_limits..per_second` | int | `50` | Sustained requests per second per client IP. `0` or less: the rule is off | +| `server.rate_limits..burst` | int | `100` | Requests allowed in a burst. Below `1`: 1 | +| `server.rate_limits.idle_ttl` | duration | `10m` | How long a client's limiter is kept after its last request; `0s`: for ever | + +#### PROXY Protocol and Proxy Headers + +| Key | Type | Default | Notes | +|---|---|---|---| +| `server.proxy.trusted` | list | `127.0.0.1/32` | IP addresses and CIDR ranges of proxies whose forwarding headers are trusted; empty: none | +| `server.proxy.client_ip_headers` | list | `X-Forwarded-For, Forwarded, X-Real-IP` | Headers read for the client IP from a trusted proxy, in priority order; empty: always the connection's address | +| `server.proxy.strip_untrusted` | bool | `true` | Remove forwarding headers from requests not from a trusted proxy | +| `server.proxy.proxy_protocol.enabled` | bool | `false` | Accept the PROXY protocol from trusted sources | +| `server.proxy.proxy_protocol.trusted_sources` | list | | Addresses and CIDR ranges allowed to send a PROXY header; empty: `server.proxy.trusted` | +| `server.proxy.proxy_protocol.required` | bool | `false` | Reject connections from trusted sources without a PROXY header | +| `server.proxy.proxy_protocol.read_header_timeout` | duration | `5s` | How long to wait for the PROXY header; `0s` for none | + +#### CSRF & Cross-Origin Protection + +| Key | Type | Default | Notes | +|---|---|---|---| +| `server.csrf.cross_origin_protection.enabled` | bool | `true` | Reject cross-origin unsafe requests (`Sec-Fetch-Site`/`Origin`) | +| `server.csrf.cross_origin_protection.trusted_origins` | list | | Extra origins (`https://admin.example.com`) allowed to make them, and to open WebSockets; empty: none | +| `server.csrf.tokens.enabled` | bool | `true` | Require a signed double-submit token on unsafe requests | +| `server.csrf.tokens.key` | string | | Hex HMAC key; empty: a random per-process key, so tokens don't survive a restart | +| `server.csrf.tokens.cookie_name` | string | `js_csrf` | Empty: `js_csrf` | +| `server.csrf.tokens.header_name` | string | `X-CSRF-Token` | Empty: `X-CSRF-Token` | +| `server.csrf.tokens.token_ttl` | duration | `12h` | `0s`: 12h | +| `server.csrf.exempt_paths` | list | | ServeMux-style patterns skipped by both CSRF layers (e.g. `/api/webhooks/`); empty: none | + +#### TLS + +| Key | Type | Default | Notes | +|---|---|---|---| +| `server.tls.enabled` | bool | `false` | Serve HTTPS | +| `server.tls.cert_file` | string | | Certificate file; must be set when TLS is enabled | +| `server.tls.key_file` | string | | Private key file; must be set when TLS is enabled | +| `server.tls.min_version` | string | `1.3` | `1.2` or `1.3`; anything else, including empty, means 1.2 | +| `server.tls.redirect_http_port` | int | | Port of a plain HTTP listener that redirects to HTTPS; `0`: none | + +#### Headers + +| Key | Type | Default | Notes | +|---|---|---|---| +| `server.headers.allowed_hosts` | list | | Allowed `Host` headers, `*.example.com` supported; empty disables the check | +| `server.headers.content_security_policy` | string | | Security response headers; empty leaves the header unset | +| `server.headers.frame_options` | string | | | +| `server.headers.referrer_policy` | string | | | +| `server.headers.permissions_policy` | string | | | +| `server.headers.cross_origin_opener_policy` | string | | | +| `server.headers.cross_origin_resource_policy` | string | | | +| `server.headers.extra_response_headers` | list of objects | | Extra headers set on every response, each `{header: ..., value: ...}`. `header` must be a valid header name and `value` must not contain a line break | +| `server.headers.remove_response_headers` | list | | Headers stripped from every response (e.g. `Server`); empty: none | +| `server.headers.hsts.enabled` | bool | `false` | Send `Strict-Transport-Security` on HTTPS requests | +| `server.headers.hsts.max_age` | duration | `8760h` | `0s`: the header isn't sent | +| `server.headers.hsts.include_subdomains` | bool | `true` | | +| `server.headers.hsts.preload` | bool | `false` | | + +#### Frontend + +Not used yet. + +| Key | Type | Default | Notes | +|---|---|---|---| +| `server.frontend.dir` | string | | Serve the frontend from this directory; empty: the embedded build | +| `server.frontend.immutable_prefix` | string | | Where Vite emits content-hashed files | +| `server.frontend.index_cache_control` | string | | `Cache-Control` of HTML and other files without a hash; empty: none | + +#### Websockets + +| Key | Type | Default | Notes | +|---|---|---|---| +| `server.websocket.enable_compression` | bool | `false` | permessage-deflate | +| `server.websocket.max_message_size` | byte size | `32KiB` | Largest message read from a client; `0B` for 32KiB. `-1` (no limit) is rejected, as a client could then exhaust the server's memory | +| `server.websocket.connection_limit.*` | | | A rate limit rule for new connections from each client IP, WebSocket and plain TCP alike; limiters are kept for `server.rate_limits.idle_ttl` | +| `server.websocket.message_rate_limit.*` | | | A rate limit rule for messages from each connection | +| `server.websocket.write_timeout` | duration | `10s` | `0s` for none | +| `server.websocket.ping_interval` | duration | `30s` | How long a connection may be idle before it is pinged; `0s`: no pings | +| `server.websocket.pong_timeout` | duration | `10s` | `0s` for none | + +## Complex Types + A rate limit rule (`JSCRateLimitRule`) has `enabled`, `per_second` and `burst`. It is off unless `enabled` and `per_second` is greater than 0; a `burst` below -1 means 1. -It is used under `server.websocket.connection_limit` and -`server.websocket.message_rate_limit`. +1 means 1. It is used under several keys: `server.rate_limits.global`, +`server.rate_limits.api`, `server.websocket.connection_limit` and +`server.websocket.message_rate_limit`. Rate limits are implemented using the +token bucket algorithm. + +Lists can be given as YAML/JSON lists or as a comma-separated string (e.g. in +an environment variable). A list of objects such as `server.headers.extra_response_headers` +is a YAML/JSON list of objects in a config file, or a JSON array in an +environment variable or `config set`: +`'[{"header":"X-Robots-Tag","value":"noindex"}]'`. + +## Example Configuration Example `config.yaml`: ```yaml @@ -80,6 +228,25 @@ sources: enabled: false interval: 1m api_root: http://localhost:8080/api/1.0 +server: + host: 0.0.0.0 + port: 9393 + max_body_bytes: 2MB + proxy: + trusted: [10.0.0.0/8] + rate_limits: + api: + enabled: true + per_second: 20 + burst: 40 + headers: + allowed_hosts: [jobs.example.com] + extra_response_headers: + - header: X-Robots-Tag + value: noindex + remove_response_headers: [Server] + hsts: + enabled: true ``` @@ -99,23 +266,42 @@ per key: `time.Duration` or a duration string in `SetValue`, and are given out as strings (`"10m0s"`) by `Map`, `Flatten` and `ValueOf`, and so by `JSON`, `YAML` and `String` too. -- `util.ByteSize` fields work the same way, with size strings such as `"32KiB"` - or `"1MB"` (KB, MB, ... are powers of 1000; KiB, MiB, ... powers of 1024), - and are given out exactly in the largest unit that divides them (`"1MB"`). - `-1` means no limit (`util.ByteSizeNoLimit`); no other size may be - negative. A setting for which no limit is unsafe rejects `-1`. +- `util.ByteSize` fields work the same way, with size strings such as + `"32KiB"` or `"1MB"` (KB, MB, ... are powers of 1000; KiB, MiB, ... powers + of 1024), and are given out exactly in the largest unit that divides them + (`"1MB"`). `-1` means no limit (`util.ByteSizeNoLimit`); no other size may + be negative. A setting for which no limit is unsafe rejects `-1`. +- `[]string` fields report `reflect.Slice` from `TypeOf` and accept either a + `[]string` or a comma-separated string such as `"a, b"` in `SetValue` (and so + in default tags). An empty list is stored as `nil`. +- A list of sections, `[]S` for a section struct `S` (e.g. `[]JSCServerHeadersExtra`), + is a single key reporting `reflect.Slice`. It is given out as a + `[]map[string]any`, one map per item, and `SetValue` accepts a `[]S`, a list + of maps (as Viper reads a YAML/JSON list of objects) or a JSON array string. + Each item starts from its defaults and its keys are set as `SetValue` sets + them. `S` implements `Section` and, like a type used under several keys, has + no `JSCK*` constant. - A section can reject values by implementing `validate(key, value) error` on - its pointer type. `JSCLogging` does this for `level`, and - `JSCServerWebSocket` for `max_message_size` (rejecting `-1`). + its pointer type. `JSCLogging` does this for `level`, `JSCSourcesSource` for + `interval`, `JSCServer` for `max_header_bytes`, `JSCServerTimeouts` for + `read_header` (rejecting 0 or less) and `JSCServerWebSocket` for + `max_message_size` (rejecting `-1`), and `JSCServerHeadersExtra` for + `header` and `value`. Each item in a list of + sections is validated whole, so a key left out of an item is checked too. Every section implements the `Section` interface defined in `section.go`: `Map`, `Flatten`, `TypeOf`, `ValueOf` and `SetValue`, each a one-line call into `section.go` (some sections also have `Keys`). The methods sit under a `// ---------- Section methods (see Section) ----------` banner in each -section's file. `TestSections` walks `JobSearchConfig` and checks every +section's file. A section's `Flatten` keys are prefixed with its `JSCK*` +constant, except for a section type used under several keys, such as +`JSCRateLimitRule`: it has no constant and its keys are unprefixed. +`TestSections` walks `JobSearchConfig` and checks every section, including embedded ones, implements `Section` and that its -`Flatten` keys match its `cfg` tag, so a new section is checked without being -listed anywhere. +`Flatten` keys match its `cfg` tag, and that its `Map`, `TypeOf`, `ValueOf` +and `SetValue` agree with the whole configuration's view of the same keys (the +items of a list of sections are checked the same way), so a new section is +checked without being listed anywhere. ## Adding a key or section @@ -126,7 +312,8 @@ else is needed for it to be loaded, printed, and read or written by the To add a new section: 1. Create a `conf_*.go` file with the section struct, a `JSCK*` constant for its key, and an implementation of `Section` (copy an existing section's - methods). + methods). A type that will be used under several keys, like + `JSCRateLimitRule`, has no constant and passes `""` to `flattenOf`. 2. Add the struct as a tagged field of its parent: `JobSearchConfig` in `config.go`, or `JSCSources` in `conf_sources.go` for a source, etc. The field's `cfg` tag must match the `JSCK*` constant. @@ -165,7 +352,7 @@ Nothing in `section.go` needs changing. | `conf_sources.go` | `sources` section and the shared source fields | | `conf_source_*.go` | One file per source section | | `conf_server.go` | `server` section | -| `conf_server_*.go` | One file per `server` sub-section (`rate_limits`, `timeouts`, `websocket`) | +| `conf_server_*.go` | One file per `server` sub-section (`csrf`, `frontend`, `headers`, `proxy`, `rate_limits`, `timeouts`, `tls`, `websocket`) | | `conf_ratelimitrule.go` | `JSCRateLimitRule`, the rate limit rule shared by several keys | | `print.go`, `debug.go` | Printing and debug logging of the configuration | diff --git a/internal/config/conf_sections_test.go b/internal/config/conf_sections_test.go index 0fe2d12..8b6d198 100644 --- a/internal/config/conf_sections_test.go +++ b/internal/config/conf_sections_test.go @@ -372,6 +372,18 @@ func TestSerialisedRoundTrip(t *testing.T) { orig.Sources.FTSMock.Enabled = true orig.Sources.FTSMock.APIRoot = "http://mock" orig.Server.WebSocket.MaxMessageSize = 64 << 10 + orig.Server.MaxBodyBytes = 2 << 20 + orig.Server.Headers.ExtraResponseHeaders = []JSCServerHeadersExtra{ + { + Header: "X-Robots-Tag", + Value: "noindex", + }, + { + Header: "Cache-Control", + Value: "no-store, max-age=0", + }, + } + orig.Server.Headers.RemoveResponseHeaders = []string{"Server", "X-Powered-By"} for _, format := range []string{"json", "yaml"} { t.Run(format, func(t *testing.T) { @@ -449,6 +461,12 @@ func TestViperRoundTrip(t *testing.T) { orig.Logging.Level = "error" orig.Sources.FindATender.Enabled = true orig.Sources.FindATender.Interval = 45 * time.Second + orig.Server.Headers.ExtraResponseHeaders = []JSCServerHeadersExtra{ + { + Header: "X-Robots-Tag", + Value: "noindex", + }, + } LoadToViper(orig) var got JobSearchConfig @@ -458,6 +476,98 @@ func TestViperRoundTrip(t *testing.T) { } } +// A list of sections can be given as a JSON array string, as it would be in +// an environment variable. +func TestLoadFromViperSectionListString(t *testing.T) { + resetViper(t) + viper.Set( + "server.headers.extra_response_headers", + `[{"header": "X-Robots-Tag", "value": "noindex"}]`, + ) + conf := defaultConfig(t) + LoadFromViper(&conf) + + want := []JSCServerHeadersExtra{ + { + Header: "X-Robots-Tag", + Value: "noindex", + }, + } + if !reflect.DeepEqual(conf.Server.Headers.ExtraResponseHeaders, want) { + t.Errorf( + "ExtraResponseHeaders = %+v; want %+v", + conf.Server.Headers.ExtraResponseHeaders, + want, + ) + } +} + +func TestHeadersExtraValidation(t *testing.T) { + var c JSCServerHeadersExtra + for _, h := range []string{"X-Robots-Tag", "x-a", "A1!#$%&'*+-.^_`|~"} { + if err := c.SetValue("header", h); err != nil || c.Header != h { + t.Errorf("SetValue(header, %q) = %v, Header = %q", h, err, c.Header) + } + } + for _, h := range []string{ + "", "X Robots", "X-A:", "X-A\r\nX-B", "Ünicode", "(x)", + } { + if err := c.SetValue("header", h); err == nil || + !strings.Contains(err.Error(), "invalid header name") { + t.Errorf("SetValue(header, %q) = %v; want invalid header name", h, err) + } + } + if err := c.SetValue("value", "no-store, max-age=0"); err != nil { + t.Errorf("SetValue(value): %v", err) + } + for _, v := range []string{"a\nb", "a\rb", "a\r\nX-Evil: 1"} { + if err := c.SetValue("value", v); err == nil || + !strings.Contains(err.Error(), "invalid header value") { + t.Errorf("SetValue(value, %q) = %v; want invalid header value", v, err) + } + } + + // Items in the list are validated whole, so a missing header is rejected + // too, and the list is left as it was. + const key = "server.headers.extra_response_headers" + conf := defaultConfig(t) + for _, value := range []any{ + `[{"value": "noindex"}]`, + `[{"header": "X-A", "value": "1"}, {"header": "bad name", "value": "2"}]`, + `[{"header": "X-A", "value": "a\nb"}]`, + []JSCServerHeadersExtra{ + { + Value: "noindex", + }, + }, + } { + if err := conf.SetValue(key, value); err == nil || + !strings.Contains(err.Error(), "invalid header") { + t.Errorf("SetValue(%s, %#v) = %v; want invalid header", key, value, err) + } + if conf.Server.Headers.ExtraResponseHeaders != nil { + t.Errorf( + "rejected list was stored: %+v", + conf.Server.Headers.ExtraResponseHeaders, + ) + } + } + + // From Viper, an invalid list is skipped with a warning. + resetViper(t) + logs := captureLogs(t, slog.LevelWarn) + viper.Set(key, []any{map[string]any{"value": "noindex"}}) + LoadFromViper(&conf) + if conf.Server.Headers.ExtraResponseHeaders != nil || + !strings.Contains(logs.String(), "key="+key) { + t.Errorf( + "invalid list from Viper: stored %+v, logs %s", + conf.Server.Headers.ExtraResponseHeaders, + logs, + ) + } +} + // Invalid values from Viper are skipped with a warning, leaving the existing // value. func TestLoadFromViperInvalidValues(t *testing.T) { @@ -620,3 +730,26 @@ func TestSourceIntervalMustBePositive(t *testing.T) { ) } } + +// server.timeouts.read_header must be greater than 0; the other server +// timeouts accept 0 (no limit). +func TestServerReadHeaderTimeoutMustBePositive(t *testing.T) { + conf := defaultConfig(t) + for _, value := range []string{"0s", "-1s"} { + err := conf.SetValue("server.timeouts.read_header", value) + if err == nil || !strings.Contains(err.Error(), "greater than 0") { + t.Errorf("SetValue(read_header, %s) = %v; want rejected", value, err) + } + } + if conf.Server.Timeouts.ReadHeaderTimeout != 5*time.Second { + t.Errorf( + "rejected read_header was stored: %v", + conf.Server.Timeouts.ReadHeaderTimeout, + ) + } + for _, key := range []string{"read", "write", "idle", "shutdown"} { + if err := conf.SetValue("server.timeouts."+key, "0s"); err != nil { + t.Errorf("SetValue(%s, 0s): %v", key, err) + } + } +} diff --git a/internal/config/conf_server.go b/internal/config/conf_server.go index dfeeb13..87fc06c 100644 --- a/internal/config/conf_server.go +++ b/internal/config/conf_server.go @@ -1,7 +1,10 @@ package config import ( + "errors" "reflect" + + "git.avsdev.uk/Go/job-search/internal/util" ) // JSCKServer is the server section's configuration key. @@ -9,13 +12,22 @@ const JSCKServer string = "server" // JSCServer configures the outward server. Host is the address to listen // on (empty means every interface) and Port the port (0 means any free -// port). +// port). MaxHeaderBytes is the largest request header block; 0 means the +// default (8KB), and -1 (no limit) is rejected (see validate). MaxBodyBytes is +// the largest request body; 0 means the default (1MB) and -1 no limit. type JSCServer struct { - Host string `cfg:"host" default:"127.0.0.1"` - Port int `cfg:"port" default:"9393"` - Timeouts JSCServerTimeouts `cfg:"timeouts"` - RateLimits JSCServerRateLimits `cfg:"rate_limits"` - WebSocket JSCServerWebSocket `cfg:"websocket"` + Host string `cfg:"host" default:"127.0.0.1"` + Port int `cfg:"port" default:"9393"` + MaxHeaderBytes util.ByteSize `cfg:"max_header_bytes" default:"8KB"` + MaxBodyBytes util.ByteSize `cfg:"max_body_bytes" default:"1MB"` + Timeouts JSCServerTimeouts `cfg:"timeouts"` + RateLimits JSCServerRateLimits `cfg:"rate_limits"` + Proxy JSCServerProxy `cfg:"proxy"` + CSRF JSCServerCSRF `cfg:"csrf"` + TLS JSCServerTLS `cfg:"tls"` + Headers JSCServerHeaders `cfg:"headers"` + Frontend JSCServerFrontend `cfg:"frontend"` + WebSocket JSCServerWebSocket `cfg:"websocket"` } // ---------- Section methods (see Section) ---------- @@ -42,3 +54,18 @@ func (c JSCServer) ValueOf(key string) (any, error) { func (c *JSCServer) SetValue(key string, value any) error { return setValue(c, key, value) } + +// ---------- Section specific methods ---------- + +// validate checks values before they are stored (see validator). +// max_header_bytes can't be -1 (no limit): the HTTP server always limits the +// header block, so -1 would quietly mean its own default instead. +func (c *JSCServer) validate(key string, value any) error { + if key == "max_header_bytes" && + value.(util.ByteSize) == util.ByteSizeNoLimit { + return errors.New( + "config: max_header_bytes can't be -1 (no limit): set an explicit size, or 0 for the default", + ) + } + return nil +} diff --git a/internal/config/conf_server_csrf.go b/internal/config/conf_server_csrf.go new file mode 100644 index 0000000..42a56ed --- /dev/null +++ b/internal/config/conf_server_csrf.go @@ -0,0 +1,124 @@ +package config + +import ( + "reflect" + "time" +) + +// JSCKServerCSRF is the server CSRF section's configuration key. +const JSCKServerCSRF string = "csrf" + +// JSCServerCSRF configures the server's CSRF protection: its cross-origin +// check and its tokens. ExemptPaths are ServeMux-style patterns skipped by +// both (e.g. "/api/webhooks/"); empty means none are skipped. +type JSCServerCSRF struct { + CrossOriginProtection JSCServerCSRFCrossOrigin `cfg:"cross_origin_protection"` + Tokens JSCServerCSRFTokens `cfg:"tokens"` + ExemptPaths []string `cfg:"exempt_paths"` +} + +// ---------- Section methods (see Section) ---------- + +// Map implements Section. +func (c JSCServerCSRF) Map() map[string]any { return mapOf(c) } + +// Flatten implements Section. +func (c JSCServerCSRF) Flatten() map[string]any { + return flattenOf(JSCKServerCSRF, c) +} + +// TypeOf implements Section. +func (c JSCServerCSRF) TypeOf(key string) (reflect.Kind, error) { + return typeOf(c, key) +} + +// ValueOf implements Section. +func (c JSCServerCSRF) ValueOf(key string) (any, error) { + return valueOf(c, key) +} + +// SetValue implements Section. +func (c *JSCServerCSRF) SetValue(key string, value any) error { + return setValue(c, key, value) +} + +// ========== Cross-Origin Protection ========== + +// JSCKServerCSRFCrossOrigin is the server CSRF cross-origin protection +// section's configuration key. +const JSCKServerCSRFCrossOrigin string = "cross_origin_protection" + +// JSCServerCSRFCrossOrigin configures CSRF protection by checking requests' +// origin. TrustedOrigins are extra origins ("https://admin.example.com") +// allowed to make cross-origin unsafe requests; empty means none. +type JSCServerCSRFCrossOrigin struct { + Enabled bool `cfg:"enabled" default:"true"` + TrustedOrigins []string `cfg:"trusted_origins"` +} + +// ---------- Section methods (see Section) ---------- + +// Map implements Section. +func (c JSCServerCSRFCrossOrigin) Map() map[string]any { return mapOf(c) } + +// Flatten implements Section. +func (c JSCServerCSRFCrossOrigin) Flatten() map[string]any { + return flattenOf(JSCKServerCSRFCrossOrigin, c) +} + +// TypeOf implements Section. +func (c JSCServerCSRFCrossOrigin) TypeOf(key string) (reflect.Kind, error) { + return typeOf(c, key) +} + +// ValueOf implements Section. +func (c JSCServerCSRFCrossOrigin) ValueOf(key string) (any, error) { + return valueOf(c, key) +} + +// SetValue implements Section. +func (c *JSCServerCSRFCrossOrigin) SetValue(key string, value any) error { + return setValue(c, key, value) +} + +// ========== CSRF Tokens ========== + +// JSCKServerCSRFTokens is the server CSRF tokens section's configuration key. +const JSCKServerCSRFTokens string = "tokens" + +// JSCServerCSRFTokens configures CSRF protection by tokens. Key is the hex +// HMAC key tokens are signed with; empty means a random key for the process, +// so tokens don't survive a restart. An empty CookieName or HeaderName, or a +// TokenTTL of 0 or less, means the default. +type JSCServerCSRFTokens struct { + Enabled bool `cfg:"enabled" default:"true"` + Key string `cfg:"key"` + CookieName string `cfg:"cookie_name" default:"js_csrf"` + HeaderName string `cfg:"header_name" default:"X-CSRF-Token"` + TokenTTL time.Duration `cfg:"token_ttl" default:"12h"` +} + +// ---------- Section methods (see Section) ---------- + +// Map implements Section. +func (c JSCServerCSRFTokens) Map() map[string]any { return mapOf(c) } + +// Flatten implements Section. +func (c JSCServerCSRFTokens) Flatten() map[string]any { + return flattenOf(JSCKServerCSRFTokens, c) +} + +// TypeOf implements Section. +func (c JSCServerCSRFTokens) TypeOf(key string) (reflect.Kind, error) { + return typeOf(c, key) +} + +// ValueOf implements Section. +func (c JSCServerCSRFTokens) ValueOf(key string) (any, error) { + return valueOf(c, key) +} + +// SetValue implements Section. +func (c *JSCServerCSRFTokens) SetValue(key string, value any) error { + return setValue(c, key, value) +} diff --git a/internal/config/conf_server_frontend.go b/internal/config/conf_server_frontend.go new file mode 100644 index 0000000..947613c --- /dev/null +++ b/internal/config/conf_server_frontend.go @@ -0,0 +1,44 @@ +package config + +import ( + "reflect" +) + +// JSCKServerFrontend is the frontend section's configuration key. +const JSCKServerFrontend string = "frontend" + +// JSCServerFrontend configures how the server serves the frontend. Nothing +// reads it yet. Dir serves the frontend from disk instead of the embedded +// build; empty means the embedded build. ImmutablePrefix is where Vite emits +// content-hashed files, and IndexCacheControl the Cache-Control of HTML and +// other files without a hash; empty means none. +type JSCServerFrontend struct { + Dir string `cfg:"dir"` + ImmutablePrefix string `cfg:"immutable_prefix"` + IndexCacheControl string `cfg:"index_cache_control"` +} + +// ---------- Section methods (see Section) ---------- + +// Map implements Section. +func (c JSCServerFrontend) Map() map[string]any { return mapOf(c) } + +// Flatten implements Section. +func (c JSCServerFrontend) Flatten() map[string]any { + return flattenOf(JSCKServerFrontend, c) +} + +// TypeOf implements Section. +func (c JSCServerFrontend) TypeOf(key string) (reflect.Kind, error) { + return typeOf(c, key) +} + +// ValueOf implements Section. +func (c JSCServerFrontend) ValueOf(key string) (any, error) { + return valueOf(c, key) +} + +// SetValue implements Section. +func (c *JSCServerFrontend) SetValue(key string, value any) error { + return setValue(c, key, value) +} diff --git a/internal/config/conf_server_headers.go b/internal/config/conf_server_headers.go new file mode 100644 index 0000000..07df117 --- /dev/null +++ b/internal/config/conf_server_headers.go @@ -0,0 +1,176 @@ +package config + +import ( + "errors" + "reflect" + "strings" + "time" +) + +// JSCKServerHeaders is the server headers section's configuration key. +const JSCKServerHeaders string = "headers" + +// JSCServerHeaders configures the server's request checks and security +// response headers. AllowedHosts restricts the Host header, and supports +// "*.example.com"; empty disables the check. Each policy header is sent with +// its configured value; empty means it isn't sent. ExtraResponseHeaders are +// set on every response, and RemoveResponseHeaders stripped from every +// response before it is sent; empty means none. +type JSCServerHeaders struct { + AllowedHosts []string `cfg:"allowed_hosts"` + + ContentSecurityPolicy string `cfg:"content_security_policy"` + FrameOptions string `cfg:"frame_options"` + ReferrerPolicy string `cfg:"referrer_policy"` + PermissionsPolicy string `cfg:"permissions_policy"` + CrossOriginOpenerPolicy string `cfg:"cross_origin_opener_policy"` + CrossOriginResourcePolicy string `cfg:"cross_origin_resource_policy"` + HSTS JSCServerHeadersHSTS `cfg:"hsts"` + + ExtraResponseHeaders []JSCServerHeadersExtra `cfg:"extra_response_headers"` + RemoveResponseHeaders []string `cfg:"remove_response_headers"` +} + +// ---------- Section methods (see Section) ---------- + +// Map implements Section. +func (c JSCServerHeaders) Map() map[string]any { return mapOf(c) } + +// Flatten implements Section. +func (c JSCServerHeaders) Flatten() map[string]any { + return flattenOf(JSCKServerHeaders, c) +} + +// TypeOf implements Section. +func (c JSCServerHeaders) TypeOf(key string) (reflect.Kind, error) { + return typeOf(c, key) +} + +// ValueOf implements Section. +func (c JSCServerHeaders) ValueOf(key string) (any, error) { + return valueOf(c, key) +} + +// SetValue implements Section. +func (c *JSCServerHeaders) SetValue(key string, value any) error { + return setValue(c, key, value) +} + +// ========== HSTS Headers ========== + +// JSCKServerHeadersHSTS is the HSTS section's configuration key. +const JSCKServerHeadersHSTS string = "hsts" + +// JSCServerHeadersHSTS configures the Strict-Transport-Security header, sent +// on HTTPS requests when enabled. A MaxAge of 0 or less means the header +// isn't sent. +type JSCServerHeadersHSTS struct { + Enabled bool `cfg:"enabled" default:"false"` + MaxAge time.Duration `cfg:"max_age" default:"8760h"` + IncludeSubdomains bool `cfg:"include_subdomains" default:"true"` + Preload bool `cfg:"preload" default:"false"` +} + +// ---------- Section methods (see Section) ---------- + +// Map implements Section. +func (c JSCServerHeadersHSTS) Map() map[string]any { return mapOf(c) } + +// Flatten implements Section. +func (c JSCServerHeadersHSTS) Flatten() map[string]any { + return flattenOf(JSCKServerHeadersHSTS, c) +} + +// TypeOf implements Section. +func (c JSCServerHeadersHSTS) TypeOf(key string) (reflect.Kind, error) { + return typeOf(c, key) +} + +// ValueOf implements Section. +func (c JSCServerHeadersHSTS) ValueOf(key string) (any, error) { + return valueOf(c, key) +} + +// SetValue implements Section. +func (c *JSCServerHeadersHSTS) SetValue(key string, value any) error { + return setValue(c, key, value) +} + +// ========== Extra Headers ========== + +// JSCServerHeadersExtra configures one extra response header. It is used as +// the items of the extra_response_headers list, so it has no JSCK* constant +// and its Flatten keys are not prefixed. Header must be a valid header name, +// and Value must not hold a line break (see validate); an empty Value means +// the header isn't sent. +type JSCServerHeadersExtra struct { + Header string `cfg:"header"` + Value string `cfg:"value"` +} + +// ---------- Section methods (see Section) ---------- + +// Map implements Section. +func (c JSCServerHeadersExtra) Map() map[string]any { return mapOf(c) } + +// Flatten implements Section. +func (c JSCServerHeadersExtra) Flatten() map[string]any { + return flattenOf("", c) +} + +// TypeOf implements Section. +func (c JSCServerHeadersExtra) TypeOf(key string) (reflect.Kind, error) { + return typeOf(c, key) +} + +// ValueOf implements Section. +func (c JSCServerHeadersExtra) ValueOf(key string) (any, error) { + return valueOf(c, key) +} + +// SetValue implements Section. +func (c *JSCServerHeadersExtra) SetValue(key string, value any) error { + return setValue(c, key, value) +} + +// ---------- Section specific methods ---------- + +// validate checks values before they are stored (see validator). header must +// be a valid header field name (an RFC 9110 token) and value must not hold a +// line break, so neither can break the response's header block. +func (c *JSCServerHeadersExtra) validate(key string, value any) error { + switch key { + case "header": + if !isHeaderToken(value.(string)) { + return errors.New( + "config: invalid header name: must be a non-empty token such as X-Robots-Tag", + ) + } + case "value": + if strings.ContainsAny(value.(string), "\r\n") { + return errors.New( + "config: invalid header value: must not contain a line break", + ) + } + } + return nil +} + +// isHeaderToken reports whether name is a non-empty RFC 9110 token, the syntax +// of a header field name. +func isHeaderToken(name string) bool { + if name == "" { + return false + } + for _, char := range name { + switch { + case char >= 'a' && char <= 'z', + char >= 'A' && char <= 'Z', + char >= '0' && char <= '9': + case strings.ContainsRune("!#$%&'*+-.^_`|~", char): + default: + return false + } + } + return true +} diff --git a/internal/config/conf_server_proxy.go b/internal/config/conf_server_proxy.go new file mode 100644 index 0000000..0f1d070 --- /dev/null +++ b/internal/config/conf_server_proxy.go @@ -0,0 +1,88 @@ +package config + +import ( + "reflect" + "time" +) + +// JSCKServerProxy is the server proxy section's configuration key. +const JSCKServerProxy string = "proxy" + +// JSCServerProxy configures which proxies the server trusts and how it finds +// the client's address behind them. TrustedProxies are IP addresses and CIDR +// ranges; empty means no proxy is trusted. ClientIPHeaders are the headers +// read for the client's address from a trusted proxy, in priority order; +// empty means the connection's own address is always used. +type JSCServerProxy struct { + TrustedProxies []string `cfg:"trusted" default:"127.0.0.1/32"` + ClientIPHeaders []string `cfg:"client_ip_headers" default:"X-Forwarded-For, Forwarded, X-Real-IP"` + StripUntrusted bool `cfg:"strip_untrusted" default:"true"` + ProxyProtocol JSCServerProxyProtocol `cfg:"proxy_protocol"` +} + +// ---------- Section methods (see Section) ---------- + +// Map implements Section. +func (c JSCServerProxy) Map() map[string]any { return mapOf(c) } + +// Flatten implements Section. +func (c JSCServerProxy) Flatten() map[string]any { + return flattenOf(JSCKServerProxy, c) +} + +// TypeOf implements Section. +func (c JSCServerProxy) TypeOf(key string) (reflect.Kind, error) { + return typeOf(c, key) +} + +// ValueOf implements Section. +func (c JSCServerProxy) ValueOf(key string) (any, error) { + return valueOf(c, key) +} + +// SetValue implements Section. +func (c *JSCServerProxy) SetValue(key string, value any) error { + return setValue(c, key, value) +} + +// ========== PROXY protocol ========== + +// JSCKServerProxyProtocol is the server proxy PROXY protocol section's +// configuration key. +const JSCKServerProxyProtocol string = "proxy_protocol" + +// JSCServerProxyProtocol configures accepting the PROXY protocol from +// trusted proxies. TrustedSources may send PROXY headers; empty means +// Proxy.TrustedProxies. ReadHeaderTimeout is how long to wait for the PROXY +// header; 0 or less means no limit. +type JSCServerProxyProtocol struct { + Enabled bool `cfg:"enabled" default:"false"` + TrustedSources []string `cfg:"trusted_sources"` + Required bool `cfg:"required" default:"false"` + ReadHeaderTimeout time.Duration `cfg:"read_header_timeout" default:"5s"` +} + +// ---------- Section methods (see Section) ---------- + +// Map implements Section. +func (c JSCServerProxyProtocol) Map() map[string]any { return mapOf(c) } + +// Flatten implements Section. +func (c JSCServerProxyProtocol) Flatten() map[string]any { + return flattenOf(JSCKServerProxyProtocol, c) +} + +// TypeOf implements Section. +func (c JSCServerProxyProtocol) TypeOf(key string) (reflect.Kind, error) { + return typeOf(c, key) +} + +// ValueOf implements Section. +func (c JSCServerProxyProtocol) ValueOf(key string) (any, error) { + return valueOf(c, key) +} + +// SetValue implements Section. +func (c *JSCServerProxyProtocol) SetValue(key string, value any) error { + return setValue(c, key, value) +} diff --git a/internal/config/conf_server_ratelimits.go b/internal/config/conf_server_ratelimits.go index 8341468..41733f4 100644 --- a/internal/config/conf_server_ratelimits.go +++ b/internal/config/conf_server_ratelimits.go @@ -8,11 +8,14 @@ import ( // JSCKServerRateLimits is the server rate limits section's configuration key. const JSCKServerRateLimits string = "rate_limits" -// JSCServerRateLimits configures the server's rate limiters. IdleTTL is how -// long a client's limiter is kept after its last request; 0 or less means -// limiters are never evicted. +// JSCServerRateLimits configures the server's rate limits. Global limits +// every request from each client, and API each client's API requests (see +// JSCRateLimitRule). IdleTTL is how long a client's limiter is kept after its +// last request; 0 or less means limiters are never evicted. type JSCServerRateLimits struct { - IdleTTL time.Duration `cfg:"idle_ttl" default:"10m"` + Global JSCRateLimitRule `cfg:"global"` + API JSCRateLimitRule `cfg:"api"` + IdleTTL time.Duration `cfg:"idle_ttl" default:"10m"` } // ---------- Section methods (see Section) ---------- diff --git a/internal/config/conf_server_timeouts.go b/internal/config/conf_server_timeouts.go index ee70f9f..8bddb04 100644 --- a/internal/config/conf_server_timeouts.go +++ b/internal/config/conf_server_timeouts.go @@ -1,6 +1,7 @@ package config import ( + "errors" "reflect" "time" ) @@ -8,11 +9,19 @@ import ( // JSCKServerTimeouts is the server timeouts section's configuration key. const JSCKServerTimeouts string = "timeouts" -// JSCServerTimeouts configures the server's timeouts. ShutdownTimeout is how -// long shutdown waits for clients to close; 0 or less means it waits until -// they have. +// JSCServerTimeouts configures the server's HTTP timeouts, as those of +// http.Server: ReadHeaderTimeout for reading a request's headers, +// ReadTimeout for the whole request, WriteTimeout for writing the response +// and IdleTimeout for waiting for the next request on a keep-alive +// connection. 0 or less means no limit, except that ReadHeaderTimeout must be +// greater than 0 (see validate). ShutdownTimeout is how long shutdown waits +// for clients to close; 0 or less means it waits until they have. type JSCServerTimeouts struct { - ShutdownTimeout time.Duration `cfg:"shutdown" default:"15s"` + ReadHeaderTimeout time.Duration `cfg:"read_header" default:"5s"` + ReadTimeout time.Duration `cfg:"read" default:"15s"` + WriteTimeout time.Duration `cfg:"write" default:"30s"` + IdleTimeout time.Duration `cfg:"idle" default:"2m"` + ShutdownTimeout time.Duration `cfg:"shutdown" default:"15s"` } // ---------- Section methods (see Section) ---------- @@ -39,3 +48,17 @@ func (c JSCServerTimeouts) ValueOf(key string) (any, error) { func (c *JSCServerTimeouts) SetValue(key string, value any) error { return setValue(c, key, value) } + +// ---------- Section specific methods ---------- + +// validate checks values before they are stored (see validator). +// read_header must be greater than 0: without it a client could hold a +// connection open for ever by sending its headers slowly (slowloris). +func (c *JSCServerTimeouts) validate(key string, value any) error { + if key == "read_header" && value.(time.Duration) <= 0 { + return errors.New( + "config: read_header must be greater than 0, as a client could otherwise hold a connection open for ever", + ) + } + return nil +} diff --git a/internal/config/conf_server_tls.go b/internal/config/conf_server_tls.go new file mode 100644 index 0000000..f989cd1 --- /dev/null +++ b/internal/config/conf_server_tls.go @@ -0,0 +1,46 @@ +package config + +import ( + "reflect" +) + +// JSCKServerTLS is the TLS section's configuration key. +const JSCKServerTLS string = "tls" + +// JSCServerTLS configures the server's TLS connections. CertFile and KeyFile +// must be set when TLS is enabled. MinVersion is "1.2" or "1.3"; anything +// else, including empty, means 1.2. RedirectHTTPPort, if set (e.g. 80), +// starts a plain HTTP listener on that port that redirects everything to +// HTTPS; 0 means none. +type JSCServerTLS struct { + Enabled bool `cfg:"enabled" default:"false"` + CertFile string `cfg:"cert_file"` + KeyFile string `cfg:"key_file"` + MinVersion string `cfg:"min_version" default:"1.3"` + RedirectHTTPPort int `cfg:"redirect_http_port"` +} + +// ---------- Section methods (see Section) ---------- + +// Map implements Section. +func (c JSCServerTLS) Map() map[string]any { return mapOf(c) } + +// Flatten implements Section. +func (c JSCServerTLS) Flatten() map[string]any { + return flattenOf(JSCKServerTLS, c) +} + +// TypeOf implements Section. +func (c JSCServerTLS) TypeOf(key string) (reflect.Kind, error) { + return typeOf(c, key) +} + +// ValueOf implements Section. +func (c JSCServerTLS) ValueOf(key string) (any, error) { + return valueOf(c, key) +} + +// SetValue implements Section. +func (c *JSCServerTLS) SetValue(key string, value any) error { + return setValue(c, key, value) +} diff --git a/internal/config/conf_server_websocket.go b/internal/config/conf_server_websocket.go index 92e3b02..fa71b6c 100644 --- a/internal/config/conf_server_websocket.go +++ b/internal/config/conf_server_websocket.go @@ -11,11 +11,12 @@ import ( // JSCKServerWebSocket is the websocket section's configuration key. const JSCKServerWebSocket string = "websocket" -// JSCServerWebSocket configures the server's websocket connections. -// MaxMessageSize is the largest message read from a client; 0 means the -// default (32KiB), and -1 (no limit) is rejected (see validate). A -// WriteTimeout or PongTimeout of 0 or less means no limit, and a -// PingInterval of 0 or less turns pings off. +// JSCServerWebSocket configures the server's websocket connections. A +// browser's upgrade must come from the server's own origin or one of +// csrf.cross_origin_protection.trusted_origins. MaxMessageSize is the +// largest message read from a client; 0 means the default (32KiB), and -1 +// (no limit) is rejected (see validate). A WriteTimeout or PongTimeout of 0 +// or less means no limit, and a PingInterval of 0 or less turns pings off. type JSCServerWebSocket struct { EnableCompression bool `cfg:"enable_compression" default:"false"` MaxMessageSize util.ByteSize `cfg:"max_message_size" default:"32KiB"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8c1f785..835e358 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -55,6 +55,13 @@ var sectionType = reflect.TypeOf((*Section)(nil)).Elem() // new section is checked without being listed anywhere. func TestSections(t *testing.T) { conf := defaultConfig(t) + // An item in each list of sections, so the items' methods are checked. + conf.Server.Headers.ExtraResponseHeaders = []JSCServerHeadersExtra{ + { + Header: "X-Robots-Tag", + Value: "noindex", + }, + } if !reflect.PointerTo(reflect.TypeOf(conf)).Implements(sectionType) { t.Error("*JobSearchConfig does not implement Section") } diff --git a/internal/config/modify_test.go b/internal/config/modify_test.go index c7591e2..835385e 100644 --- a/internal/config/modify_test.go +++ b/internal/config/modify_test.go @@ -70,6 +70,48 @@ func TestStoreValue(t *testing.T) { } } +// A list of sections is given to StoreValue as a JSON array, written to the +// file as a list of objects and read back from it. +func TestStoreValueSectionList(t *testing.T) { + path := useConfigFile(t, "logging:\n level: warn\n") + conf := defaultConfig(t) + const key = "server.headers.extra_response_headers" + _, kind, err := StoreValue( + &conf, key, `[{"header": "X-Robots-Tag", "value": "noindex"}]`, + ) + if err != nil || kind != reflect.Slice { + t.Fatalf("StoreValue = <%s>, %v", kind, err) + } + want := []JSCServerHeadersExtra{ + { + Header: "X-Robots-Tag", + Value: "noindex", + }, + } + if !reflect.DeepEqual(conf.Server.Headers.ExtraResponseHeaders, want) { + t.Errorf( + "conf has %+v; want %+v", + conf.Server.Headers.ExtraResponseHeaders, + want, + ) + } + + resetViper(t) + viper.SetConfigFile(path) + if err := viper.ReadInConfig(); err != nil { + t.Fatal(err) + } + reread := defaultConfig(t) + LoadFromViper(&reread) + if !reflect.DeepEqual(reread.Server.Headers.ExtraResponseHeaders, want) { + t.Errorf( + "file read back as %+v; want %+v", + reread.Server.Headers.ExtraResponseHeaders, + want, + ) + } +} + func TestStoreValueKeepsOtherKeys(t *testing.T) { path := useConfigFile(t, "logging:\n level: warn\n file: /tmp/js.log\n") conf := defaultConfig(t) diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go new file mode 100644 index 0000000..e264a5e --- /dev/null +++ b/internal/httpx/httpx.go @@ -0,0 +1,18 @@ +// Package httpx holds small HTTP helpers shared by middleware and handlers. +package httpx + +import ( + "encoding/json" + "net/http" +) + +// WriteJSON writes value as JSON with the given status. A nil value, or 204 No +// Content, writes no body. +func WriteJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if value == nil || status == http.StatusNoContent { + return + } + json.NewEncoder(w).Encode(value) //nolint:errcheck,gosec // the client may have gone +} diff --git a/internal/httpx/httpx_test.go b/internal/httpx/httpx_test.go index ae9e88f..d442059 100644 --- a/internal/httpx/httpx_test.go +++ b/internal/httpx/httpx_test.go @@ -8,6 +8,59 @@ import ( "testing" ) +func TestWriteJSON(t *testing.T) { + rec := httptest.NewRecorder() + WriteJSON(rec, http.StatusCreated, map[string]any{"id": "x", "n": 1}) + + if rec.Code != http.StatusCreated { + t.Errorf("status = %d; want 201", rec.Code) + } + ct := rec.Header().Get("Content-Type") + if ct != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q", ct) + } + if body := rec.Body.String(); body != `{"id":"x","n":1}`+"\n" { + t.Errorf("body = %q", body) + } +} + +// A nil value, or 204 No Content, writes the status and headers but no body. +func TestWriteJSONNoBody(t *testing.T) { + tests := []struct { + name string + status int + v any + }{ + {"nil value", http.StatusOK, nil}, + {"no content", http.StatusNoContent, map[string]string{"ignored": "yes"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + WriteJSON(rec, tt.status, tt.v) + if rec.Code != tt.status || rec.Body.Len() != 0 { + t.Errorf( + "got %d %q; want %d and no body", rec.Code, rec.Body, tt.status, + ) + } + ct := rec.Header().Get("Content-Type") + if ct != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q", ct) + } + }) + } +} + +// A value that can't be encoded still gets its status; the body is empty. +func TestWriteJSONUnencodable(t *testing.T) { + rec := httptest.NewRecorder() + WriteJSON(rec, http.StatusOK, func() { + }) + if rec.Code != http.StatusOK || rec.Body.Len() != 0 { + t.Errorf("got %d %q; want 200 and no body", rec.Code, rec.Body) + } +} + func TestWriteProblem(t *testing.T) { req := httptest.NewRequest("GET", "/api/items/1", nil) req = req.WithContext(WithRequestID(req.Context(), "req-123")) diff --git a/internal/httpx/request_id.go b/internal/httpx/request_id.go index 5c61845..2a4ffa5 100644 --- a/internal/httpx/request_id.go +++ b/internal/httpx/request_id.go @@ -4,8 +4,10 @@ import ( "context" ) +// ctxKey is the type of this package's context keys. type ctxKey int +// requestIDKey is the context key of the request ID. const requestIDKey ctxKey = iota // WithRequestID stores a request ID in the context. diff --git a/internal/middleware/compress.go b/internal/middleware/compress.go new file mode 100644 index 0000000..c3da9d8 --- /dev/null +++ b/internal/middleware/compress.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "net/http" + + "github.com/klauspost/compress/gzhttp" +) + +// Compress returns gzip/zstd response compression using klauspost/compress, +// which is considerably faster than compress/gzip. Responses that already +// carry a Content-Encoding (e.g. precompressed frontend assets) are passed +// through untouched. +// +// Request-body decompression is left disabled on purpose (decompression bombs). +// +// Do not apply this to the WebSocket route. +func Compress() (func(http.Handler) http.Handler, error) { + wrap, err := gzhttp.NewWrapper( + gzhttp.MinSize(1000), + gzhttp.CompressionLevel(5), + gzhttp.EnableZstd(true), + ) + if err != nil { + return nil, err + } + return func(next http.Handler) http.Handler { + return wrap(next) + }, nil +} diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go new file mode 100644 index 0000000..187de27 --- /dev/null +++ b/internal/middleware/csrf.go @@ -0,0 +1,303 @@ +package middleware + +import ( + "cmp" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "git.avsdev.uk/Go/job-search/internal/config" + "git.avsdev.uk/Go/job-search/internal/httpx" +) + +// Errors returned by CSRF.Validate for a token that is rejected. +var ( + // ErrCSRFMissing is returned when no token is presented. + ErrCSRFMissing = errors.New("csrf token missing") + // ErrCSRFNoCookie is returned when the request has no CSRF cookie. + ErrCSRFNoCookie = errors.New("csrf cookie missing") + // ErrCSRFMismatch is returned when the token differs from the cookie. + ErrCSRFMismatch = errors.New("csrf token does not match cookie") + // ErrCSRFInvalid is returned for a malformed token or a bad signature. + ErrCSRFInvalid = errors.New("csrf token invalid") + // ErrCSRFExpired is returned for a token older than the token TTL. + ErrCSRFExpired = errors.New("csrf token expired") +) + +// CSRF implements two independent layers: +// +// 1. http.CrossOriginProtection (Go >=1.25): rejects unsafe cross-origin +// browser requests using Sec-Fetch-Site, falling back to Origin vs Host. +// 2. Signed double-submit token: an HttpOnly cookie plus the same value in a +// request header. The value is HMAC-signed and timestamped, so an +// attacker who can plant cookies (e.g. from a sibling subdomain) still +// cannot mint a valid token. This layer also covers requests that carry +// neither Sec-Fetch-Site nor Origin. +// +// A token can be obtained with GET /api/csrf and sent in the header for API +// consumers. +type CSRF struct { + cop *http.CrossOriginProtection + token bool + key []byte + cookieName string + headerName string + ttl time.Duration + exempt *http.ServeMux +} + +// The defaults NewCSRF uses for an empty cookie or header name, or a token +// TTL of 0 or less, as the configuration's defaults. +const ( + // defaultCSRFCookieName is the cookie the token is set in. + defaultCSRFCookieName = "js_csrf" + // defaultCSRFHeaderName is the request header that carries the token. + defaultCSRFHeaderName = "X-CSRF-Token" + // defaultCSRFTokenTTL is how long a token is valid. + defaultCSRFTokenTTL = 12 * time.Hour +) + +// Sizes of a token's parts. +const ( + // csrfNonceLen is the size of a token's random nonce. + csrfNonceLen = 16 + // csrfPayloadLen is the size of a token's signed payload: the nonce and + // the time it was issued (unix seconds). + csrfPayloadLen = csrfNonceLen + 8 +) + +// NewCSRF builds the CSRF layers from conf. Without a configured key, tokens +// are signed with a random key for this process, and a warning is logged if +// tokens are enabled. It logs to slog.Default() unless given WithLogger. +func NewCSRF(conf config.JSCServerCSRF, opts ...Option) (*CSRF, error) { + csrf := &CSRF{ + token: conf.Tokens.Enabled, + cookieName: cmp.Or(conf.Tokens.CookieName, defaultCSRFCookieName), + headerName: http.CanonicalHeaderKey( + cmp.Or(conf.Tokens.HeaderName, defaultCSRFHeaderName), + ), + ttl: conf.Tokens.TokenTTL, + exempt: http.NewServeMux(), + } + if csrf.ttl <= 0 { + csrf.ttl = defaultCSRFTokenTTL + } + + if conf.Tokens.Key != "" { + key, err := hex.DecodeString(conf.Tokens.Key) + if err != nil { + return nil, fmt.Errorf("csrf key: %w", err) + } + csrf.key = key + } else { + csrf.key = make([]byte, 32) + rand.Read(csrf.key) // crypto/rand.Read never returns an error + if conf.Tokens.Enabled { + applyOptions(opts).logger("csrf").Warn( + "csrf key not set; using a random per-process key, so tokens " + + "won't survive a restart or be valid across instances", + ) + } + } + + noop := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + }) + for _, pattern := range conf.ExemptPaths { + csrf.exempt.Handle(pattern, noop) + } + + if conf.CrossOriginProtection.Enabled { + csrf.cop = http.NewCrossOriginProtection() + for _, origin := range conf.CrossOriginProtection.TrustedOrigins { + if err := csrf.cop.AddTrustedOrigin(origin); err != nil { + return nil, fmt.Errorf("csrf trusted origin %q: %w", origin, err) + } + } + for _, pattern := range conf.ExemptPaths { + csrf.cop.AddInsecureBypassPattern(pattern) + } + csrf.cop.SetDenyHandler(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + httpx.WriteProblem( + w, r, http.StatusForbidden, "cross-origin request rejected", + ) + }, + )) + } + + return csrf, nil +} + +// HeaderName is the request header that must carry the token. +func (c *CSRF) HeaderName() string { return c.headerName } + +// TokenEnabled reports whether the token layer is active. +func (c *CSRF) TokenEnabled() bool { return c.token } + +// Protect applies both layers to unsafe methods, except on exempt paths. +func (c *CSRF) Protect(next http.Handler) http.Handler { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if c.token && !isSafeMethod(r.Method) && !c.isExempt(r) { + if err := c.Validate(r, r.Header.Get(c.headerName)); err != nil { + httpx.WriteProblem(w, r, http.StatusForbidden, err.Error()) + return + } + } + next.ServeHTTP(w, r) + }) + + if c.cop != nil { + return c.cop.Handler(handler) + } + + return handler +} + +// Validate checks a presented token against the cookie and its signature. +func (c *CSRF) Validate(r *http.Request, presented string) error { + if presented == "" { + return ErrCSRFMissing + } + + cookie, err := r.Cookie(c.cookieNameFor(IsSecure(r))) + if err != nil || cookie.Value == "" { + return ErrCSRFNoCookie + } + + if subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(presented)) != 1 { + return ErrCSRFMismatch + } + + issued, err := c.verify(presented) + if err != nil { + return err + } + + if time.Since(issued) > c.ttl { + return ErrCSRFExpired + } + + return nil +} + +// IssueHandler serves GET /api/csrf: it (re)issues the cookie and returns +// the token for the client to echo back in the header. A still-fresh token +// is reused so parallel tabs do not invalidate each other. +func (c *CSRF) IssueHandler(w http.ResponseWriter, r *http.Request) { + secure := IsSecure(r) + name := c.cookieNameFor(secure) + + token := "" + issued := time.Time{} + if cookie, err := r.Cookie(name); err == nil { + cookieIssued, err := c.verify(cookie.Value) + if err == nil && time.Since(cookieIssued) < c.ttl/2 { + token, issued = cookie.Value, cookieIssued + } + } + + if token == "" { + issued = time.Now() + token = c.mint(issued) + // Secure is false only on plain HTTP (local development), where + // browsers refuse Secure cookies; HttpOnly and SameSite are always set. + http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: see above + Name: name, + Value: token, + Path: "/", + MaxAge: int(c.ttl.Seconds()), + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteStrictMode, + }) + } + + httpx.WriteJSON(w, http.StatusOK, map[string]any{ + "token": token, + "header": c.headerName, + "expiresAt": issued.Add(c.ttl).UTC(), + }) +} + +// mint returns a new token issued at now: a random nonce and the issue time, +// and their signature (see sign), each base64url encoded and joined by ".". +func (c *CSRF) mint(now time.Time) string { + payload := make([]byte, csrfPayloadLen) + rand.Read(payload[:csrfNonceLen]) // crypto/rand.Read never returns an error + + binary.BigEndian.PutUint64( + payload[csrfNonceLen:], + uint64(now.Unix()), //nolint:gosec // G115: the current time is after 1970 + ) + + encoding := base64.RawURLEncoding + + return encoding.EncodeToString(payload) + "." + + encoding.EncodeToString(c.sign(payload)) +} + +// verify checks token's format and signature and returns when it was issued, +// or ErrCSRFInvalid. +func (c *CSRF) verify(token string) (time.Time, error) { + encodedPayload, encodedSignature, ok := strings.Cut(token, ".") + if !ok { + return time.Time{}, ErrCSRFInvalid + } + + encoding := base64.RawURLEncoding + payload, payloadErr := encoding.DecodeString(encodedPayload) + signature, signatureErr := encoding.DecodeString(encodedSignature) + if payloadErr != nil || signatureErr != nil || + len(payload) != csrfPayloadLen { + return time.Time{}, ErrCSRFInvalid + } + + if !hmac.Equal(signature, c.sign(payload)) { + return time.Time{}, ErrCSRFInvalid + } + + issuedUnix := binary.BigEndian.Uint64(payload[csrfNonceLen:]) + // The payload is signed, so this is a time mint wrote: after 1970. + return time.Unix(int64(issuedUnix), 0), nil //nolint:gosec // G115: see above +} + +// sign returns the HMAC-SHA256 of payload under the CSRF key. +func (c *CSRF) sign(payload []byte) []byte { + mac := hmac.New(sha256.New, c.key) + mac.Write([]byte("csrf-v1|")) + mac.Write(payload) + + return mac.Sum(nil) +} + +// cookieNameFor drops the __Host-/__Secure- prefix on plain HTTP (local +// development), because browsers refuse such cookies without Secure. +func (c *CSRF) cookieNameFor(secure bool) string { + if secure { + return c.cookieName + } + name := strings.TrimPrefix(c.cookieName, "__Host-") + return strings.TrimPrefix(name, "__Secure-") +} + +// isExempt reports whether r's path matches one of the exempt paths. +func (c *CSRF) isExempt(r *http.Request) bool { + _, pattern := c.exempt.Handler(r) + return pattern != "" +} + +// isSafeMethod reports whether method is safe (it changes nothing), so +// needs no CSRF protection. +func isSafeMethod(method string) bool { + return method == http.MethodGet || method == http.MethodHead || + method == http.MethodOptions +} diff --git a/internal/middleware/csrf_test.go b/internal/middleware/csrf_test.go new file mode 100644 index 0000000..184b8a9 --- /dev/null +++ b/internal/middleware/csrf_test.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "git.avsdev.uk/Go/job-search/internal/config" +) + +func TestCrossOriginRejected(t *testing.T) { + var cfg config.JSCServerCSRF + cfg.CrossOriginProtection.Enabled = true + c, err := NewCSRF(cfg, WithLogger(slog.New(slog.DiscardHandler))) + if err != nil { + t.Fatal(err) + } + h := c.Protect(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + })) + wants := map[string]int{ + "cross-site": http.StatusForbidden, + "same-origin": http.StatusOK, + } + for site, want := range wants { + req := httptest.NewRequest("POST", "/api/items", nil) + req.Header.Set("Sec-Fetch-Site", site) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != want { + t.Errorf("%s: got %d, want %d", site, rec.Code, want) + } + } +} diff --git a/internal/middleware/default_headers.go b/internal/middleware/default_headers.go new file mode 100644 index 0000000..8ec1057 --- /dev/null +++ b/internal/middleware/default_headers.go @@ -0,0 +1,86 @@ +package middleware + +import ( + "fmt" + "net/http" + + "git.avsdev.uk/Go/job-search/internal/config" +) + +// DefaultHeaders sets a number of security related response headers. Using +// configuration settings any extra headers are added and any headers to be +// removed are stripped. +func DefaultHeaders( + conf config.JSCServerHeaders, +) func(http.Handler) http.Handler { + type header struct { + name, value string + } + var static []header + + addHeader := func(name, value string) { + if value != "" { + static = append(static, header{ + name: http.CanonicalHeaderKey(name), + value: value, + }) + } + } + + addHeader("Content-Security-Policy", conf.ContentSecurityPolicy) + addHeader("X-Frame-Options", conf.FrameOptions) + addHeader("Referrer-Policy", conf.ReferrerPolicy) + addHeader("Permissions-Policy", conf.PermissionsPolicy) + addHeader("Cross-Origin-Opener-Policy", conf.CrossOriginOpenerPolicy) + addHeader("Cross-Origin-Resource-Policy", conf.CrossOriginResourcePolicy) + addHeader("X-Content-Type-Options", "nosniff") + for _, extra := range conf.ExtraResponseHeaders { + addHeader(extra.Header, extra.Value) + } + + hsts := "" + if seconds := int64(conf.HSTS.MaxAge.Seconds()); seconds > 0 { + hsts = fmt.Sprintf("max-age=%d", seconds) + if conf.HSTS.IncludeSubdomains { + hsts += "; includeSubDomains" + } + if conf.HSTS.Preload { + hsts += "; preload" + } + } + + remove := make([]string, 0, len(conf.RemoveResponseHeaders)) + for _, name := range conf.RemoveResponseHeaders { + remove = append(remove, http.CanonicalHeaderKey(name)) + } + strip := func(headers http.Header) { + for _, name := range remove { + headers.Del(name) + } + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers := w.Header() + + for _, staticHeader := range static { + headers.Set(staticHeader.name, staticHeader.value) + } + + // HSTS is only meaningful (and only honoured) over HTTPS. + if hsts != "" && IsSecure(r) { + headers.Set("Strict-Transport-Security", hsts) + } + + if len(remove) == 0 { + next.ServeHTTP(w, r) + return + } + + next.ServeHTTP(&responseRecorder{ + ResponseWriter: w, + beforeHeader: strip, + }, r) + }) + } +} diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go new file mode 100644 index 0000000..642b835 --- /dev/null +++ b/internal/middleware/logging.go @@ -0,0 +1,195 @@ +package middleware + +import ( + "bufio" + "crypto/rand" + "errors" + "log/slog" + "net" + "net/http" + "time" + + "git.avsdev.uk/Go/job-search/internal/httpx" +) + +// responseRecorder captures status and size while staying transparent to +// http.ResponseController (Unwrap) and to code that type-asserts for +// http.Hijacker / http.Flusher (needed by the WebSocket upgrade). +type responseRecorder struct { + http.ResponseWriter + status int + bytes int64 + hijacked bool + // beforeHeader, if set, runs once just before headers are written. + beforeHeader func(http.Header) +} + +// WriteHeader records the status, running beforeHeader the first time, and +// writes it. +func (w *responseRecorder) WriteHeader(code int) { + if w.status == 0 { + w.status = code + if w.beforeHeader != nil { + w.beforeHeader(w.Header()) + } + } + w.ResponseWriter.WriteHeader(code) +} + +// Write writes data, writing a 200 status first if none has been, and counts +// the bytes written. +func (w *responseRecorder) Write(data []byte) (int, error) { + if w.status == 0 { + w.WriteHeader(http.StatusOK) + } + + written, err := w.ResponseWriter.Write(data) + w.bytes += int64(written) + + return written, err +} + +// Unwrap returns the wrapped ResponseWriter, for http.ResponseController. +func (w *responseRecorder) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + +// Flush writes a 200 status first if none has been, then flushes the +// wrapped ResponseWriter if it can. +func (w *responseRecorder) Flush() { + if w.status == 0 { + w.WriteHeader(http.StatusOK) + } + http.NewResponseController(w.ResponseWriter).Flush() //nolint:errcheck,gosec // none returned +} + +// Hijack takes over the connection, recording it as hijacked with status 101 +// (switching protocols) if no status was written. +func (w *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + conn, readWriter, err := http.NewResponseController(w.ResponseWriter).Hijack() + if err == nil { + w.hijacked = true + if w.status == 0 { + w.status = http.StatusSwitchingProtocols + } + } + return conn, readWriter, err +} + +// RequestID assigns each request an ID, exposed as X-Request-ID. An incoming +// X-Request-ID is only accepted from trusted proxies and only if well formed. +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := "" + if Client(r).ViaProxy { + if incoming := r.Header.Get("X-Request-ID"); validRequestID(incoming) { + id = incoming + } + } + if id == "" { + id = rand.Text() + } + + w.Header().Set("X-Request-ID", id) + next.ServeHTTP(w, r.WithContext(httpx.WithRequestID(r.Context(), id))) + }) +} + +// validRequestID reports whether id is an acceptable request ID: 1 to 64 +// characters, each a letter, digit, '-', '_' or '.'. +func validRequestID(id string) bool { + if id == "" || len(id) > 64 { + return false + } + for i := 0; i < len(id); i++ { + if !isRequestIDChar(id[i]) { + return false + } + } + return true +} + +// isRequestIDChar reports whether char may appear in a request ID. +func isRequestIDChar(char byte) bool { + switch { + case char >= 'a' && char <= 'z', + char >= 'A' && char <= 'Z', + char >= '0' && char <= '9': + return true + } + return char == '-' || char == '_' || char == '.' +} + +// Logger logs one line per request. The query string is deliberately not +// logged, as it may carry secrets. It logs to slog.Default() unless given +// WithLogger. +func Logger(opts ...Option) func(http.Handler) http.Handler { + settings := applyOptions(opts) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + recorder := &responseRecorder{ + ResponseWriter: w, + } + + defer func() { + status := recorder.status + if status == 0 { + status = http.StatusOK + } + + level := slog.LevelInfo + switch { + case status >= 500: + level = slog.LevelError + case status >= 400: + level = slog.LevelWarn + } + + settings.logger("http").LogAttrs( + r.Context(), + level, + "request", + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.Int("status", status), + slog.Int64("bytes", recorder.bytes), + slog.Duration("duration", time.Since(start)), + slog.String("ip", ClientIP(r)), + slog.String("proto", r.Proto), + slog.String("request_id", httpx.RequestID(r.Context())), + slog.Bool("hijacked", recorder.hijacked), + ) + }() + + next.ServeHTTP(recorder, r) + }) + } +} + +// Recoverer turns panics into 500 responses and logs them. It logs to +// slog.Default() unless given WithLogger. +func Recoverer(opts ...Option) func(http.Handler) http.Handler { + settings := applyOptions(opts) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if recovered := recover(); recovered != nil { + err, isErr := recovered.(error) + if isErr && errors.Is(err, http.ErrAbortHandler) { + panic(recovered) + } + + settings.logger("http").Error( + "panic", + "error", recovered, + "path", r.URL.Path, + "request_id", httpx.RequestID(r.Context()), + ) + httpx.WriteProblem(w, r, http.StatusInternalServerError, "") + } + }() + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/middleware/options.go b/internal/middleware/options.go new file mode 100644 index 0000000..11928f2 --- /dev/null +++ b/internal/middleware/options.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "log/slog" +) + +// Option configures a middleware that logs, such as NewCSRF, Logger or +// Recoverer. +type Option func(*options) + +// options holds the settings Options change. +type options struct { + log *slog.Logger +} + +// WithLogger makes a middleware log to logger instead of slog.Default(). +func WithLogger(logger *slog.Logger) Option { + return func(opts *options) { + opts.log = logger + } +} + +// applyOptions returns the settings opts make. +func applyOptions(opts []Option) options { + var settings options + for _, opt := range opts { + opt(&settings) + } + + return settings +} + +// logger returns the logger to use, tagged with component: the given one, or +// slog.Default(), looked up now. +func (opts options) logger(component string) *slog.Logger { + log := opts.log + if log == nil { + log = slog.Default() + } + return log.With("component", component) +} diff --git a/internal/middleware/request_checks.go b/internal/middleware/request_checks.go new file mode 100644 index 0000000..3f8541b --- /dev/null +++ b/internal/middleware/request_checks.go @@ -0,0 +1,137 @@ +package middleware + +import ( + "mime" + "net" + "net/http" + "strings" + + "git.avsdev.uk/Go/job-search/internal/config" + "git.avsdev.uk/Go/job-search/internal/httpx" +) + +// defaultMaxBodyBytes is the largest request body when max_body_bytes is 0: +// 1MB, as the configuration's default. +const defaultMaxBodyBytes = 1_000_000 + +// RequestChecks validates the Host header against headers.allowed_hosts (if +// any are set) and caps the request body at max_body_bytes: 0 means +// defaultMaxBodyBytes, and -1 (util.ByteSizeNoLimit) no limit. +func RequestChecks( + conf config.JSCServer, +) func(http.Handler) http.Handler { + allowedHosts := newHostAllowList(conf.Headers.AllowedHosts) + + maxBodyBytes := int64(conf.MaxBodyBytes) + if maxBodyBytes == 0 { + maxBodyBytes = defaultMaxBodyBytes + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !allowedHosts.allows(r.Host) { + httpx.WriteProblem( + w, r, http.StatusMisdirectedRequest, "host not allowed", + ) + return + } + + if maxBodyBytes > 0 && r.Body != nil && r.Body != http.NoBody { + if r.ContentLength > maxBodyBytes { + httpx.WriteProblem( + w, r, http.StatusRequestEntityTooLarge, "request body too large", + ) + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + } + + next.ServeHTTP(w, r) + }) + } +} + +// hostAllowList is the set of hosts a request's Host header may name: exact +// host names, and suffixes from "*.example.com" patterns. An empty list +// allows every host. +type hostAllowList struct { + exact map[string]bool + // suffixes are the domains of "*.domain" patterns, e.g. ".example.com". + suffixes []string +} + +// newHostAllowList builds a hostAllowList from host names and "*.domain" +// patterns, ignoring case and blank entries. +func newHostAllowList(hosts []string) hostAllowList { + list := hostAllowList{ + exact: map[string]bool{}, + } + for _, host := range hosts { + host = strings.ToLower(strings.TrimSpace(host)) + if strings.HasPrefix(host, "*.") { + list.suffixes = append(list.suffixes, host[1:]) + } else if host != "" { + list.exact[host] = true + } + } + return list +} + +// allows reports whether the Host header host (with or without a port, and +// a trailing dot) is allowed. A suffix matches subdomains only, not the bare +// domain. +func (l hostAllowList) allows(host string) bool { + if len(l.exact) == 0 && len(l.suffixes) == 0 { + return true + } + + if hostname, _, err := net.SplitHostPort(host); err == nil { + host = hostname + } + + host = strings.ToLower(strings.TrimSuffix(host, ".")) + if l.exact[host] { + return true + } + + for _, suffix := range l.suffixes { + if strings.HasSuffix(host, suffix) && len(host) > len(suffix) { + return true + } + } + + return false +} + +// RequireJSON rejects requests with a body that is not JSON (415 Unsupported +// Media Type). +func RequireJSON(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hasBody := r.ContentLength > 0 || len(r.TransferEncoding) > 0 + + if hasBody { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + isJSON := mediaType == "application/json" || + strings.HasSuffix(mediaType, "+json") + if err != nil || !isJSON { + httpx.WriteProblem( + w, + r, + http.StatusUnsupportedMediaType, + "Content-Type must be application/json", + ) + return + } + } + + next.ServeHTTP(w, r) + }) +} + +// NoStore marks responses as non-cacheable. +func NoStore(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..979c7f0 --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,109 @@ +package server + +import ( + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "git.avsdev.uk/Go/job-search/internal/config" + "git.avsdev.uk/Go/job-search/internal/httpx" + "git.avsdev.uk/Go/job-search/internal/middleware" +) + +// routes builds the router. Middleware order (outermost first): +// +// RealIP -> RequestID -> Logger -> Recoverer -> [global rate limit + hooks] +// -> RequestChecks (host, body cap) -> DefaultHeaders +// /api : NoStore -> Compress -> [api rate limit + hooks] -> CSRF +// -> RequireJSON +// /ws : [ws rate limit + hooks] -> Origin check (hub) +func (s *Server) routes() (http.Handler, error) { + conf := s.conf + router := chi.NewRouter() + + router.Use( + middleware.RealIP(middleware.RealIPOptions{ + Trusted: s.trusted, + Headers: conf.Proxy.ClientIPHeaders, + StripUntrusted: conf.Proxy.StripUntrusted, + }), + middleware.RequestID, + middleware.Logger(middleware.WithLogger(s.untaggedLogger())), + middleware.Recoverer(middleware.WithLogger(s.untaggedLogger())), + ) + router.Use(s.groupStack(GroupGlobal, conf.RateLimits.Global)...) + router.Use( + middleware.RequestChecks(*conf), + middleware.DefaultHeaders(conf.Headers), + ) + + compress, err := middleware.Compress() + if err != nil { + return nil, err + } + + // API + router.Route("/api", func(apiRouter chi.Router) { + apiRouter.Use(middleware.NoStore, compress) + apiRouter.Use(s.groupStack(GroupAPI, conf.RateLimits.API)...) + apiRouter.Use(s.csrf.Protect, middleware.RequireJSON) + + apiRouter.NotFound(func(w http.ResponseWriter, r *http.Request) { + httpx.WriteProblem(w, r, http.StatusNotFound, "no such endpoint") + }) + apiRouter.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { + httpx.WriteProblem(w, r, http.StatusMethodNotAllowed, "") + }) + + apiRouter.Get("/health", s.health) + if s.csrf.TokenEnabled() { + apiRouter.Get("/csrf", s.csrf.IssueHandler) + } + }) + + // WebSocket (no compression middleware: permessage-deflate is separate) + webSocketStack := s.groupStack(GroupWebSocket, conf.WebSocket.ConnectionLimit) + router.With(webSocketStack...).Get("/ws", s.hub.ServeHTTP) + + return router, nil +} + +// groupStack returns the rate-limit hook (configured or overridden) followed +// by any user-supplied hook middleware for the group. +func (s *Server) groupStack( + group Group, rule config.JSCRateLimitRule, +) []Middleware { + var stack []Middleware + if hook, ok := s.limiters[group]; ok && hook.set { + if hook.limiter != nil { + stack = append(stack, middleware.RateLimit(hook.limiter, hook.key)) + } + } else if rule.Enabled && rule.PerSecond > 0 { + limiter := middleware.NewKeyedLimiter( + s.backgroundCtx, + float64(rule.PerSecond), + max(rule.Burst, 1), + s.conf.RateLimits.IdleTTL, + ) + stack = append(stack, middleware.RateLimit(limiter, middleware.KeyByIP)) + } + return append(stack, s.hooks[group]...) +} + +// health serves GET /api/health: the server's status and uptime, the number +// of WebSocket clients, and the client as the server sees it (after proxy +// processing). +func (s *Server) health(w http.ResponseWriter, r *http.Request) { + client := middleware.Client(r) + httpx.WriteJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "uptime": time.Since(s.started).Round(time.Second).String(), + "wsClients": s.hub.Count(), + "client": map[string]any{ + "ip": client.IP.String(), + "scheme": client.Scheme, + "viaProxy": client.ViaProxy, + }, + }) +} diff --git a/internal/server/run_test.go b/internal/server/run_test.go new file mode 100644 index 0000000..889f2e7 --- /dev/null +++ b/internal/server/run_test.go @@ -0,0 +1,183 @@ +package server + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "git.avsdev.uk/Go/job-search/internal/config" +) + +func freeAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + return ln.Addr().String() +} + +func startRun( + t *testing.T, mutate func(*config.JSCServer), +) (addr string, stop func() error) { + t.Helper() + cfg := defaultConf(t) + addr = freeAddr(t) + host, port, _ := net.SplitHostPort(addr) + cfg.Host = host + cfg.Port, _ = strconv.Atoi(port) + cfg.Timeouts.ShutdownTimeout = 3 * time.Second + if mutate != nil { + mutate(cfg) + } + s, err := New(cfg, WithLogger(slog.New(slog.DiscardHandler))) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- s.Run(ctx) + }() + + deadline := time.Now().Add(3 * time.Second) + for { + c, err := net.Dial("tcp", addr) + if err == nil { + c.Close() + break + } + if time.Now().After(deadline) { + t.Fatal("server did not start") + } + time.Sleep(20 * time.Millisecond) + } + return addr, func() error { + cancel() + select { + case err := <-done: + return err + case <-time.After(5 * time.Second): + return errors.New("shutdown timed out") + } + } +} + +// A PROXY v1 header from a trusted source sets the client IP. +func TestProxyProtocol(t *testing.T) { + addr, stop := startRun(t, func(c *config.JSCServer) { + c.Proxy.ProxyProtocol.Enabled = true + }) + defer stop() + + conn, err := net.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + io.WriteString(conn, "PROXY TCP4 198.51.100.7 10.0.0.1 51234 8080\r\n"+ + "GET /api/health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + res, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var out map[string]map[string]any + json.NewDecoder(res.Body).Decode(&out) + if out["client"]["ip"] != "198.51.100.7" { + t.Fatalf("PROXY header not applied: %v", out["client"]) + } + + // Plain HTTP (no PROXY header) still works when not required. + res2, err := http.Get("http://" + addr + "/api/health") + if err != nil { + t.Fatal(err) + } + res2.Body.Close() + if res2.StatusCode != 200 { + t.Fatalf("plain request: %d", res2.StatusCode) + } +} + +// Shutdown closes live WebSockets with 1001 and Run returns cleanly. +func TestGracefulShutdownClosesWebSockets(t *testing.T) { + addr, stop := startRun(t, nil) + jar, _ := cookiejar.New(nil) + hc := &http.Client{ + Jar: jar, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + url := "ws://" + addr + "/ws" + conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ + HTTPClient: hc, + }) + if err != nil { + t.Fatal(err) + } + if _, _, err := conn.Read(ctx); err != nil { // welcome + t.Fatal(err) + } + + readErr := make(chan error, 1) + go func() { + _, _, err := conn.Read(ctx) + readErr <- err + }() + + if err := stop(); err != nil { + t.Fatalf("Run returned error: %v", err) + } + err = <-readErr + if websocket.CloseStatus(err) != websocket.StatusGoingAway { + t.Fatalf("want close 1001, got %v", err) + } + if !strings.Contains(err.Error(), "shutting down") { + t.Fatalf("unexpected close reason: %v", err) + } +} + +// The HTTP listener redirects to the same host and path over HTTPS, on the +// TLS listener's port (left out when it is 443). +func TestHTTPSRedirect(t *testing.T) { + tests := []struct { + tlsAddr, host, want string + }{ + {"127.0.0.1:9393", "example.com:8080", "https://example.com:9393/x?y=1"}, + {"127.0.0.1:9393", "example.com", "https://example.com:9393/x?y=1"}, + {"127.0.0.1:443", "example.com:80", "https://example.com/x?y=1"}, + {"[::1]:9393", "[2001:db8::1]:8080", "https://[2001:db8::1]:9393/x?y=1"}, + {"[::1]:443", "[2001:db8::1]:80", "https://[2001:db8::1]/x?y=1"}, + } + for _, tt := range tests { + req := httptest.NewRequest("GET", "http://"+tt.host+"/x?y=1", nil) + rec := httptest.NewRecorder() + httpsRedirect(tt.tlsAddr).ServeHTTP(rec, req) + location := rec.Header().Get("Location") + if rec.Code != http.StatusPermanentRedirect || location != tt.want { + t.Errorf( + "%s via %s: %d %q; want 308 %q", + tt.host, + tt.tlsAddr, + rec.Code, + location, + tt.want, + ) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..16663ad --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,378 @@ +// Package server assembles the HTTP server for the serve command. +package server + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "net/netip" + "strconv" + "strings" + "time" + + "github.com/pires/go-proxyproto" + + "git.avsdev.uk/Go/job-search/internal/config" + "git.avsdev.uk/Go/job-search/internal/middleware" + "git.avsdev.uk/Go/job-search/internal/util" + "git.avsdev.uk/Go/job-search/internal/websocket" +) + +// Group identifies where hook middleware is inserted. +type Group int + +// The route groups hook middleware can be added to (see WithMiddleware). +const ( + // GroupGlobal is every request, after real-IP, request-ID and logging. + GroupGlobal Group = iota + // GroupAPI is /api/*, before CSRF protection. + GroupAPI + // GroupWebSocket is /ws upgrade requests. + GroupWebSocket +) + +// Middleware is the standard net/http middleware shape. +type Middleware = func(http.Handler) http.Handler + +// Option customises a Server. +type Option func(*Server) + +// WithLogger makes the server, its middleware and its WebSocket hub log to +// logger instead of slog.Default(). +func WithLogger(logger *slog.Logger) Option { + return func(s *Server) { + s.log = 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 { + return func(server *Server) { + server.hooks[group] = append(server.hooks[group], middlewares...) + } +} + +// WithRateLimiter replaces the configured rate limiter of a group (for +// example with a Redis-backed implementation). Pass a nil Limiter to +// disable limiting for the group. A nil key defaults to the client IP. +func WithRateLimiter( + group Group, limiter middleware.Limiter, key middleware.KeyFunc, +) Option { + return func(server *Server) { + server.limiters[group] = limiterHook{ + limiter: limiter, + key: key, + set: true, + } + } +} + +// limiterHook is a group's rate limiter set by WithRateLimiter, replacing the +// configured one. set distinguishes a nil limiter (no limiting) from none. +type limiterHook struct { + limiter middleware.Limiter + key middleware.KeyFunc + set bool +} + +// Server is the application server. +type Server struct { + conf *config.JSCServer + log *slog.Logger + hooks map[Group][]Middleware + limiters map[Group]limiterHook + + // trusted are the proxies whose forwarding headers are believed. + trusted []netip.Prefix + // proxyProtocolTrusted are the sources allowed to send a PROXY header. + proxyProtocolTrusted []netip.Prefix + csrf *middleware.CSRF + hub *websocket.Hub + started time.Time + handler http.Handler + // backgroundCtx is for background work, such as the rate limiters' + // janitors; backgroundCancel stops it. + backgroundCtx context.Context + backgroundCancel context.CancelFunc +} + +// New builds the server and its router. It logs to slog.Default() unless +// given WithLogger. +func New(conf *config.JSCServer, opts ...Option) (*Server, error) { + server := &Server{ + conf: conf, + hooks: make(map[Group][]Middleware), + limiters: make(map[Group]limiterHook), + started: time.Now(), + } + for _, option := range opts { + option(server) + } + + var err error + server.trusted, err = util.ParseNetPrefixes(conf.Proxy.TrustedProxies) + if err != nil { + return nil, fmt.Errorf("parsing proxy.trusted: %w", err) + } + + proxyProtocolSources := conf.Proxy.ProxyProtocol.TrustedSources + if len(proxyProtocolSources) == 0 { + proxyProtocolSources = conf.Proxy.TrustedProxies + } + server.proxyProtocolTrusted, err = util.ParseNetPrefixes( + proxyProtocolSources, + ) + if err != nil { + return nil, fmt.Errorf( + "parsing proxy.proxy_protocol.trusted_sources: %w", err, + ) + } + + server.csrf, err = middleware.NewCSRF( + conf.CSRF, middleware.WithLogger(server.untaggedLogger()), + ) + if err != nil { + return nil, err + } + + server.hub = websocket.NewHub( + conf.WebSocket, + websocket.WithLogger(server.untaggedLogger()), + websocket.WithTrustedOrigins( + conf.CSRF.CrossOriginProtection.TrustedOrigins, + ), + ) + + server.backgroundCtx, server.backgroundCancel = context.WithCancel( + context.Background(), + ) + server.handler, err = server.routes() + if err != nil { + server.backgroundCancel() + return nil, err + } + + return server, nil +} + +// untaggedLogger returns the logger given WithLogger, or slog.Default(), for +// the server's middleware and hub to tag with their own components. +func (s *Server) untaggedLogger() *slog.Logger { + if s.log == nil { + return slog.Default() + } + return s.log +} + +// logger returns the logger the server logs to, tagged with its component. +func (s *Server) logger() *slog.Logger { + return s.untaggedLogger().With("component", "server") +} + +// Handler exposes the root handler (useful for httptest). +func (s *Server) Handler() http.Handler { return s.handler } + +// Run listens and serves until ctx is cancelled, then shuts down gracefully. +// With TLS, it also serves an HTTP to HTTPS redirect if tls.redirect_http_port +// is set. +func (s *Server) Run(ctx context.Context) error { + defer s.backgroundCancel() + conf := s.conf + + addr := net.JoinHostPort(conf.Host, strconv.Itoa(conf.Port)) + + listen, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("listen %s: %w", addr, err) + } + if conf.Proxy.ProxyProtocol.Enabled { + listen = &proxyproto.Listener{ + Listener: listen, + ConnPolicy: s.proxyPolicy, + ReadHeaderTimeout: noLimitIfUnset( + conf.Proxy.ProxyProtocol.ReadHeaderTimeout, + ), + } + } + + httpServer := &http.Server{ + Handler: s.handler, + ReadHeaderTimeout: noLimitIfUnset(conf.Timeouts.ReadHeaderTimeout), + ReadTimeout: conf.Timeouts.ReadTimeout, + WriteTimeout: conf.Timeouts.WriteTimeout, + IdleTimeout: noLimitIfUnset(conf.Timeouts.IdleTimeout), + MaxHeaderBytes: maxHeaderBytes(conf), + ErrorLog: slog.NewLogLogger( + s.logger().Handler(), slog.LevelWarn, + ), + } + + if conf.TLS.Enabled { + minVersion := uint16(tls.VersionTLS12) + if conf.TLS.MinVersion == "1.3" { + minVersion = tls.VersionTLS13 + } + httpServer.TLSConfig = &tls.Config{ + MinVersion: minVersion, + } + } + + serveErrors := make(chan error, 2) + go func() { + s.logger().Info( + "listening", + "addr", listen.Addr().String(), + "tls", conf.TLS.Enabled, + "proxy_protocol", conf.Proxy.ProxyProtocol.Enabled, + ) + var err error + if conf.TLS.Enabled { + err = httpServer.ServeTLS(listen, conf.TLS.CertFile, conf.TLS.KeyFile) + } else { + err = httpServer.Serve(listen) + } + serveErrors <- err + }() + + var redirect *http.Server + if conf.TLS.Enabled && conf.TLS.RedirectHTTPPort != 0 { + httpAddr := net.JoinHostPort( + conf.Host, strconv.Itoa(conf.TLS.RedirectHTTPPort), + ) + + redirect = &http.Server{ + Addr: httpAddr, + Handler: middleware.RequestChecks(*conf)(httpsRedirect(addr)), + ReadHeaderTimeout: noLimitIfUnset(conf.Timeouts.ReadHeaderTimeout), + IdleTimeout: noLimitIfUnset(conf.Timeouts.IdleTimeout), + } + go func() { + s.logger().Info("http->https redirect listening", "addr", httpAddr) + serveErrors <- redirect.ListenAndServe() + }() + } + + select { + case err := <-serveErrors: + if !errors.Is(err, http.ErrServerClosed) { + return err + } + case <-ctx.Done(): + } + + s.logger().Info("shutting down") + shutdownCtx, cancel := util.WithTimeout( + context.Background(), conf.Timeouts.ShutdownTimeout, + ) + defer cancel() + + // Stop accepting first, then close WebSockets (hijacked connections are + // not tracked by http.Server.Shutdown), then drain in-flight requests. + var errs []error + shutdownErr := make(chan error, 1) + go func() { + shutdownErr <- httpServer.Shutdown(shutdownCtx) + }() + if err := s.hub.Shutdown(shutdownCtx); err != nil { + errs = append(errs, fmt.Errorf("websocket shutdown: %w", err)) + } + if err := <-shutdownErr; err != nil { + errs = append(errs, fmt.Errorf("http shutdown: %w", err)) + } + if redirect != nil { + redirect.Shutdown(shutdownCtx) //nolint:errcheck,gosec // only redirects; nothing to drain + } + + return errors.Join(errs...) +} + +// noLimitIfUnset returns timeout for a field that reads 0 as some other +// timeout, such as http.Server's ReadHeaderTimeout and IdleTimeout (which +// use ReadTimeout) or the PROXY listener's ReadHeaderTimeout (10s): -1, +// which those fields read as no limit, if timeout is 0 or less, as the +// configuration's timeouts mean. +func noLimitIfUnset(timeout time.Duration) time.Duration { + if timeout <= 0 { + return -1 + } + return timeout +} + +// defaultMaxHeaderBytes is the largest request header block when +// max_header_bytes is 0 or less: 8KB, as the configuration's default. The +// configuration rejects -1 (no limit), as http.Server can't have one. +const defaultMaxHeaderBytes = 8_000 + +// maxHeaderBytes is the largest request header block: max_header_bytes, or +// defaultMaxHeaderBytes if that is 0 or less. +func maxHeaderBytes(conf *config.JSCServer) int { + if conf.MaxHeaderBytes <= 0 { + return defaultMaxHeaderBytes + } + return int(conf.MaxHeaderBytes) +} + +// proxyPolicy decides per connection how to treat a PROXY header. Only +// trusted sources may supply one. Untrusted peers are SKIPped (the header is +// not parsed; a spoofed one becomes garbage to the HTTP parser and fails). +// Never return a non-ErrInvalidUpstream error here: it would stop Accept. +func (s *Server) proxyPolicy( + options proxyproto.ConnPolicyOptions, +) (proxyproto.Policy, error) { + ip := addrOf(options.Upstream) + for _, prefix := range s.proxyProtocolTrusted { + if ip.IsValid() && prefix.Contains(ip) { + if s.conf.Proxy.ProxyProtocol.Required { + return proxyproto.REQUIRE, nil + } + return proxyproto.USE, nil + } + } + return proxyproto.SKIP, nil +} + +// addrOf returns the IP address of a connection's address (unmapped to IPv4 +// if it is an IPv4-mapped IPv6 address), or the zero Addr if it has none. +func addrOf(netAddr net.Addr) netip.Addr { + switch tcpAddr := netAddr.(type) { + case *net.TCPAddr: + ip, _ := netip.AddrFromSlice(tcpAddr.IP) + return ip.Unmap() + default: + addrPort, err := netip.ParseAddrPort(netAddr.String()) + if err != nil { + return netip.Addr{} + } + return addrPort.Addr().Unmap() + } +} + +// httpsRedirect redirects every request permanently to the same host and +// path over HTTPS, on the port of tlsAddr (the TLS listener's address), +// which is left out if it is 443. +func httpsRedirect(tlsAddr string) http.Handler { + _, port, _ := net.SplitHostPort(tlsAddr) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := r.Host + if hostname, _, err := net.SplitHostPort(host); err == nil { + host = hostname + } + if strings.Contains(host, ":") { // bare IPv6 + host = "[" + host + "]" + } + if port != "" && port != "443" { + host = net.JoinHostPort(strings.Trim(host, "[]"), port) + } + // The redirect only switches the scheme (and port) of the URL the + // client asked for, on the host it asked for, so it can't send the + // client anywhere else; RequestChecks has checked that host. + http.Redirect( //nolint:gosec // G710: see above + w, r, "https://"+host+r.URL.RequestURI(), http.StatusPermanentRedirect, + ) + }) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..d88e08c --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,220 @@ +package server + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/spf13/viper" + + "git.avsdev.uk/Go/job-search/internal/config" + "git.avsdev.uk/Go/job-search/internal/middleware" + ws "git.avsdev.uk/Go/job-search/internal/websocket" +) + +// defaultConf returns the server configuration's defaults, as Init would +// load them with no config file or environment. +func defaultConf(t *testing.T) *config.JSCServer { + t.Helper() + viper.Reset() + t.Cleanup(viper.Reset) + config.LoadDefaultConfig() + var conf config.JobSearchConfig + config.LoadFromViper(&conf) + return &conf.Server +} + +func newTestServer( + t *testing.T, mutate func(*config.JSCServer), opts ...Option, +) *httptest.Server { + t.Helper() + cfg := defaultConf(t) + if mutate != nil { + mutate(cfg) + } + opts = append([]Option{WithLogger(slog.New(slog.DiscardHandler))}, opts...) + s, err := New(cfg, opts...) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(s.Handler()) + t.Cleanup(func() { + ts.Close() + s.backgroundCancel() + }) + return ts +} + +type csrfToken struct { + Token string `json:"token"` + Header string `json:"header"` +} + +func getToken(t *testing.T, c *http.Client, base string) csrfToken { + t.Helper() + res, err := c.Get(base + "/api/csrf") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var tok csrfToken + if err := json.NewDecoder(res.Body).Decode(&tok); err != nil { + t.Fatal(err) + } + return tok +} + +func clientWithJar(t *testing.T) *http.Client { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + return &http.Client{ + Jar: jar, + Timeout: 5 * time.Second, + } +} + +// Unsafe API requests need the CSRF token: without it they are rejected +// before routing; with it they reach the router (here, 405 from a GET-only +// route). +func TestAPICSRF(t *testing.T) { + ts := newTestServer(t, nil) + c := clientWithJar(t) + tok := getToken(t, c, ts.URL) + + post := func(withToken bool) int { + req, _ := http.NewRequest( + "POST", ts.URL+"/api/health", strings.NewReader("{}"), + ) + req.Header.Set("Content-Type", "application/json") + if withToken { + req.Header.Set(tok.Header, tok.Token) + } + res, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + return res.StatusCode + } + if code := post(false); code != http.StatusForbidden { + t.Errorf("POST without token: got %d, want 403", code) + } + if code := post(true); code != http.StatusMethodNotAllowed { + t.Errorf("POST with token: got %d, want 405", code) + } +} + +// Every route group sees the ClientInfo built by RealIP, not the fallback +// from middleware.Client: ViaProxy is only set when RealIP honoured a header. +func TestRealIPCoversAllGroups(t *testing.T) { + groups := map[Group]string{ + GroupGlobal: "/", + GroupAPI: "/api/health", + GroupWebSocket: "/ws", + } + for g, path := range groups { + var ran, viaProxy bool + hook := func(http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ran, viaProxy = true, middleware.Client(r).ViaProxy + w.WriteHeader(http.StatusNoContent) + }) + } + // 127.0.0.1 is trusted by default. + ts := newTestServer(t, nil, WithMiddleware(g, hook)) + req, _ := http.NewRequest("GET", ts.URL+path, nil) + req.Header.Set("X-Forwarded-For", "203.0.113.9") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if !ran || !viaProxy { + t.Errorf( + "group %d (%s): hook ran=%v, ViaProxy=%v", g, path, ran, viaProxy, + ) + } + } +} + +// A WebSocket upgrade is refused from a foreign origin, accepted from an +// origin in csrf.cross_origin_protection.trusted_origins, and the client then +// gets replies. +func TestWebSocket(t *testing.T) { + ts := newTestServer(t, func(conf *config.JSCServer) { + conf.CSRF.CrossOriginProtection.TrustedOrigins = []string{ + "https://admin.example.com", + } + }) + c := clientWithJar(t) + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws" + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + foreign := &websocket.DialOptions{ + HTTPClient: c, + HTTPHeader: http.Header{"Origin": []string{"https://evil.example"}}, + } + _, res, err := websocket.Dial(ctx, wsURL, foreign) + if err == nil || res.StatusCode != http.StatusForbidden { + t.Fatalf("foreign origin: expected 403, err=%v", err) + } + + trusted := &websocket.DialOptions{ + HTTPClient: c, + HTTPHeader: http.Header{"Origin": []string{"https://admin.example.com"}}, + } + conn, _, err := websocket.Dial(ctx, wsURL, trusted) + if err != nil { + t.Fatal(err) + } + defer conn.CloseNow() + + read := func() ws.Envelope { + var e ws.Envelope + if err := wsjson.Read(ctx, conn, &e); err != nil { + t.Fatal(err) + } + return e + } + if e := read(); e.Type != ws.TypeWelcome { + t.Fatalf("want welcome, got %+v", e) + } + wsjson.Write(ctx, conn, ws.Envelope{ + Type: ws.TypePing, + ID: "1", + }) + if e := read(); e.Type != ws.TypePong || e.ID != "1" { + t.Fatalf("want pong, got %+v", e) + } + + conn.Close(websocket.StatusNormalClosure, "") +} + +// A timeout of 0 or less is passed on as -1, which http.Server and the PROXY +// listener read as no limit rather than as another timeout. +func TestNoLimitIfUnset(t *testing.T) { + tests := []struct { + in, want time.Duration + }{ + {0, -1}, + {-time.Second, -1}, + {time.Second, time.Second}, + } + for _, tt := range tests { + if got := noLimitIfUnset(tt.in); got != tt.want { + t.Errorf("noLimitIfUnset(%v) = %v; want %v", tt.in, got, tt.want) + } + } +} diff --git a/internal/util/parse_net_prefixes.go b/internal/util/parse_net_prefixes.go new file mode 100644 index 0000000..3161eab --- /dev/null +++ b/internal/util/parse_net_prefixes.go @@ -0,0 +1,41 @@ +package util + +import ( + "net/netip" + "strings" +) + +// ParseNetPrefixes parses a list of IP addresses and CIDR ranges, e.g. for a +// list of trusted proxies. Entries are trimmed and blank ones skipped. A CIDR +// range is masked to its network address (10.1.2.3/8 becomes 10.0.0.0/8), and +// an address becomes a prefix holding only that address, with an IPv4-mapped +// IPv6 address (::ffff:1.2.3.4) unmapped to IPv4. The first entry that can't +// be parsed returns an error naming it. +func ParseNetPrefixes(list []string) ([]netip.Prefix, error) { + prefixes := make([]netip.Prefix, 0, len(list)) + + for _, entry := range list { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + + if strings.Contains(entry, "/") { + prefix, err := netip.ParsePrefix(entry) + if err != nil { + return nil, err + } + prefixes = append(prefixes, prefix.Masked()) + continue + } + + addr, err := netip.ParseAddr(entry) + if err != nil { + return nil, err + } + + addr = addr.Unmap() + prefixes = append(prefixes, netip.PrefixFrom(addr, addr.BitLen())) + } + return prefixes, nil +} diff --git a/internal/util/parse_net_prefixes_test.go b/internal/util/parse_net_prefixes_test.go new file mode 100644 index 0000000..afffacb --- /dev/null +++ b/internal/util/parse_net_prefixes_test.go @@ -0,0 +1,99 @@ +package util + +import ( + "net/netip" + "slices" + "strings" + "testing" +) + +func TestParseNetPrefixes(t *testing.T) { + tests := []struct { + name string + list []string + want []string + }{ + {"nil list", nil, []string{}}, + {"IPv4 address", []string{"192.0.2.1"}, []string{"192.0.2.1/32"}}, + {"IPv6 address", []string{"2001:db8::1"}, []string{"2001:db8::1/128"}}, + {"IPv4-mapped address is unmapped", []string{"::ffff:192.0.2.1"}, []string{"192.0.2.1/32"}}, + {"CIDR range", []string{"10.0.0.0/8"}, []string{"10.0.0.0/8"}}, + {"CIDR range is masked", []string{"10.1.2.3/8"}, []string{"10.0.0.0/8"}}, + {"IPv6 CIDR range is masked", []string{"2001:db8::1/32"}, []string{"2001:db8::/32"}}, + { + "trimmed, blanks skipped, order kept", + []string{" 127.0.0.1 ", "", " ", "\t10.0.0.0/8\n", "::1"}, + []string{"127.0.0.1/32", "10.0.0.0/8", "::1/128"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseNetPrefixes(tt.list) + if err != nil { + t.Fatalf("ParseNetPrefixes(%q): %v", tt.list, err) + } + if got == nil { + t.Fatalf("ParseNetPrefixes(%q) = nil; want an empty list", tt.list) + } + gotStrings := []string{} + for _, p := range got { + gotStrings = append(gotStrings, p.String()) + } + if !slices.Equal(gotStrings, tt.want) { + t.Errorf( + "ParseNetPrefixes(%q) = %v; want %v", tt.list, gotStrings, tt.want, + ) + } + }) + } +} + +// The prefixes hold the addresses they should: a single address only itself, +// a range everything in it. +func TestParseNetPrefixesContains(t *testing.T) { + got, err := ParseNetPrefixes([]string{"192.0.2.1", "10.0.0.0/8"}) + if err != nil { + t.Fatal(err) + } + tests := []struct { + addr string + want bool + }{ + {"192.0.2.1", true}, + {"192.0.2.2", false}, + {"10.255.0.1", true}, + {"11.0.0.1", false}, + } + for _, tt := range tests { + addr := netip.MustParseAddr(tt.addr) + contained := slices.ContainsFunc(got, func(p netip.Prefix) bool { + return p.Contains(addr) + }) + if contained != tt.want { + t.Errorf("%s contained = %v; want %v", tt.addr, contained, tt.want) + } + } +} + +// The first entry that can't be parsed is an error naming it, and no +// prefixes are returned. +func TestParseNetPrefixesErrors(t *testing.T) { + for _, entry := range []string{ + "not-an-ip", + "192.0.2.256", + "10.0.0.0/33", + "10.0.0.0/x", + "2001:db8::/129", + "192.0.2.1:80", + } { + got, err := ParseNetPrefixes([]string{"127.0.0.1", entry}) + if err == nil || !strings.Contains(err.Error(), entry) { + t.Errorf( + "ParseNetPrefixes(%q) error = %v; want one naming it", entry, err, + ) + } + if got != nil { + t.Errorf("ParseNetPrefixes(%q) = %v; want nil", entry, got) + } + } +} diff --git a/internal/websocket/http.go b/internal/websocket/http.go index 2c7d008..7855ed3 100644 --- a/internal/websocket/http.go +++ b/internal/websocket/http.go @@ -10,6 +10,9 @@ import ( ) // ServeHTTP upgrades an http connection and runs the client until it closes. +// A browser's upgrade must come from the server's own origin or a trusted one +// (see WithTrustedOrigins); a request without an Origin header, which only a +// non-browser client sends, is accepted. func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { mode := ws.CompressionDisabled if h.conf.EnableCompression { @@ -17,9 +20,11 @@ func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Accept enforces the Origin check: same host, or one of OriginPatterns. - // That is the defence against cross-site WebSocket hijacking. + // That is the defence against cross-site WebSocket hijacking. A pattern + // holding "://" is matched against the origin's scheme and host. conn, err := ws.Accept(w, r, &ws.AcceptOptions{ CompressionMode: mode, + OriginPatterns: h.trustedOrigins, }) if err != nil { h.logger().Debug("upgrade failed", "error", err) diff --git a/internal/websocket/http_test.go b/internal/websocket/http_test.go index 57ef039..5de4555 100644 --- a/internal/websocket/http_test.go +++ b/internal/websocket/http_test.go @@ -47,9 +47,10 @@ func dialWS(t *testing.T, ctx context.Context, url string) *ws.Conn { return conn } -// A client upgraded over HTTP gets replies. A max_message_size of 0 uses the -// default limit rather than refusing every message. -func TestHTTPMessages(t *testing.T) { +// A hub without a CSRF handler accepts upgrades without a token, and the +// client gets replies. A max_message_size of 0 uses the default limit rather +// than refusing every message. +func TestHTTPNoCSRF(t *testing.T) { h := NewHub( config.JSCServerWebSocket{ WriteTimeout: time.Second, @@ -74,6 +75,52 @@ func TestHTTPMessages(t *testing.T) { } } +// An upgrade is accepted without an Origin (a non-browser client), from the +// server's own origin or a trusted one, and refused from any other origin. +func TestHTTPOrigins(t *testing.T) { + h := NewHub( + config.JSCServerWebSocket{}, + WithLogger(discardLog), + WithTrustedOrigins([]string{"https://admin.example.com"}), + ) + url := wsHub(t, h) + self := "http" + strings.TrimPrefix(url, "ws") + + tests := []struct { + origin string + ok bool + }{ + {"", true}, + {self, true}, + {"https://admin.example.com", true}, + {"http://admin.example.com", false}, + {"https://evil.example", false}, + } + for _, tt := range tests { + t.Run(tt.origin, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + header := http.Header{} + if tt.origin != "" { + header.Set("Origin", tt.origin) + } + conn, res, err := ws.Dial(ctx, url, &ws.DialOptions{ + HTTPHeader: header, + }) + if tt.ok { + if err != nil { + t.Fatalf("dial from %q: %v", tt.origin, err) + } + conn.CloseNow() + return + } + if err == nil || res == nil || res.StatusCode != http.StatusForbidden { + t.Fatalf("dial from %q = %v (%v); want 403", tt.origin, err, res) + } + }) + } +} + // One Push reaches WebSocket and plain TCP clients of the same hub. func TestPushReachesBothTransports(t *testing.T) { h, addr := tcpHub(t, nil) diff --git a/internal/websocket/hub.go b/internal/websocket/hub.go index 085c30f..9e83a6a 100644 --- a/internal/websocket/hub.go +++ b/internal/websocket/hub.go @@ -16,6 +16,9 @@ import ( type Hub struct { conf config.JSCServerWebSocket log *slog.Logger + // trustedOrigins are the origins, other than the server's own, that may + // open a WebSocket (see WithTrustedOrigins). + trustedOrigins []string mu sync.RWMutex clients map[*client]struct{} @@ -37,7 +40,19 @@ func WithLogger(logger *slog.Logger) Option { } } -// NewHub creates a hub. It logs to slog.Default() unless given WithLogger. +// WithTrustedOrigins lets pages from origins (such as +// "https://admin.example.com") open a WebSocket (ServeHTTP) as well as pages +// from the server's own origin, as the API's cross-origin protection does +// for csrf.cross_origin_protection.trusted_origins. +func WithTrustedOrigins(origins []string) Option { + return func(h *Hub) { + h.trustedOrigins = origins + } +} + +// NewHub creates a hub. It logs to slog.Default() unless given WithLogger, +// and accepts WebSocket upgrades only from the server's own origin unless +// given WithTrustedOrigins. func NewHub(conf config.JSCServerWebSocket, opts ...Option) *Hub { hub := &Hub{ conf: conf,