Added the HTTP server and serve command, with proxy, CSRF, TLS and security header config; wsserve is now ws serve, alongside a new ws dial
The serve command runs internal/server: the API under /api (health, and the CSRF token endpoint) and the WebSocket hub under /ws, over HTTP or TLS, with an optional HTTP to HTTPS redirect, the PROXY protocol from trusted sources and a graceful shutdown that closes WebSockets first. Its middleware covers the client IP behind trusted proxies, request IDs, request logging, panic recovery, global and API rate limits, Host and body size checks, security response headers, compression, CSRF protection (cross-origin checks and signed double-submit tokens) and JSON-only API bodies. Middleware that logs takes a WithLogger option. New server config sections: proxy, csrf, tls, headers and frontend (not used yet), plus max_header_bytes, max_body_bytes, global and API rate limits and HTTP read, write and idle timeouts. Every setting documents what 0 or empty means, and internal/config/README.md is reorganised by section. server.timeouts.read_header must be greater than 0, and a header or idle timeout of 0 means no limit rather than the read timeout. WebSocket upgrades are protected by the Origin check: the server's own origin, or one of csrf.cross_origin_protection.trusted_origins, which the hub now takes with WithTrustedOrigins. wsserve becomes "ws serve". "ws dial" opens a WebSocket on /ws to test it: --ping sends a ping and waits for the pong, --message sends messages and prints the replies, --origin tests the Origin check, and with neither --ping nor --message each line of stdin is sent until Ctrl+C.
This commit is contained in:
1 parent
f3a91ad915
commit
a279405cae
41 files changed
+3876
-78
No files matched your search
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
+222
-35
@@ -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.<source>.enabled` | bool | `false` | For every source |
|
||||
| `sources.<source>.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.<rule>.enabled` | bool | `false` | `<rule>` is `global` or `api` (see rate limit rules below) |
|
||||
| `server.rate_limits.<rule>.per_second` | int | `50` | Sustained requests per second per client IP. `0` or less: the rule is off |
|
||||
| `server.rate_limits.<rule>.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 |
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) ----------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user