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:
bwilliams committed 2026-10-15 17:47:40 +01:00
1 parent f3a91ad915
commit a279405cae
41 files changed
+3876 -78

No files matched your search

+1
View File
@@ -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))
+105
View File
@@ -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
}
+30
View File
@@ -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
}
+416
View File
@@ -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)
}
}
}
+339
View File
@@ -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")