Files
bwilliams 67215a6aaa Added CODE_CONVENTIONS.md and linters to enforce it, and applied it across the codebase
CODE_CONVENTIONS.md sets out the conventions for Go code in this
repository: naming, layout, comments, errors, logging, configuration and
tests. .golangci.json enables the linters that check the parts that can be
checked (each limited to the rules the conventions use); the rest is
applied by hand.

Applied across the codebase:
- Naming: full-word names in application code, initialisms in one case
  (BuyerID, PrintJSON, PrintYAML, ocdsGetBuyerID), no names shadowing
  built-ins (min/max are minGap/maxGap), mustRaw renamed rawJSON as it
  returns nil rather than panicking, and config.ConfigLevelToLogLevel
  renamed LevelToLogLevel.
- Layout: lines kept to 80 characters where possible, assignments taken
  out of if and for statements that don't fit, wrapped arguments all on
  one line or one per line, steps separated by blank lines, no else after
  a return, function and struct literals wrapped.
- Comments: doc comments on every exported name (including each section's
  Map, Flatten, TypeOf, ValueOf and SetValue), section banners, British
  spelling.
- Errors: a created error starts with its package name, a wrapping error
  adds only what was being done, sentinels in one commented var block,
  errors.Is and errors.As in place of == and type assertions, and no
  err := shadowing of a named result. That fixes the mock server losing
  its access log's close error.
- Logging: the fetcher passes its workers an untagged logger, so
  findatender's lines are tagged with their own component rather than
  with fetcher's as well; cmd/mock-fts logs "stopped" with slog.
- Configuration: util.WithTimeout (moved from the websocket package)
  applies a timeout of 0 or less as no limit, used for the websocket
  write and ping, TCP close and tcpserver shutdown timeouts; every setting
  documents what 0 or empty means, including the rate limit rule's keys
  in internal/config/README.md.
- Tests: related tests merged into table-driven ones with named cases.

Separately from CODE_CONVENTIONS.md, internal/store/README.md gains a soft
SQL style convention for the queries in internal/store/queries.go, and the
queries are reformatted to follow it.
2026-10-15 16:26:42 +01:00

205 lines
6.2 KiB
Go

// Package tcpserver runs a TCP server whose clients join a websocket.Hub
// directly, without an HTTP upgrade: each connection carries newline-delimited
// JSON envelopes (see websocket.Hub.ServeTCP). It can serve a hub on its own,
// or provide a hub's clients in tests.
package tcpserver
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/netip"
"strconv"
"time"
"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"
)
// rejectTimeout caps how long telling a rate limited client why it is being
// turned away may take.
const rejectTimeout = time.Second
// Server listens on the configured host and port and runs each connection as
// a client of its hub. New connections from each client IP are limited by
// websocket.connection_limit.
type Server struct {
conf *config.JSCServer
log *slog.Logger
hub *websocket.Hub
}
// Option configures a Server.
type Option func(*Server)
// WithLogger makes the server log to logger instead of slog.Default().
func WithLogger(logger *slog.Logger) Option {
return func(s *Server) {
s.log = logger
}
}
// New builds a server for hub's plain TCP clients. Run starts it; it shuts
// hub down when it stops, so hub should not be shared with another server.
// Push to hub to reach the server's clients. It logs to slog.Default()
// unless given WithLogger.
func New(conf *config.JSCServer, hub *websocket.Hub, opts ...Option) *Server {
server := &Server{
conf: conf,
hub: hub,
}
for _, opt := range opts {
opt(server)
}
return server
}
// logger returns the logger the server logs to, tagged with its component.
func (s *Server) logger() *slog.Logger {
log := s.log
if log == nil {
log = slog.Default()
}
return log.With("component", "tcp_server")
}
// Run listens on the configured host and port and serves plain TCP clients
// until ctx is cancelled, then shuts down gracefully (see serve).
func (s *Server) Run(ctx context.Context) error {
addr := net.JoinHostPort(s.conf.Host, strconv.Itoa(s.conf.Port))
listen, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
return s.serve(ctx, listen)
}
// serve accepts connections on listen until ctx is cancelled or listen fails,
// then shuts down: it stops accepting, then closes the hub's clients
// gracefully, waiting up to the shutdown timeout (for ever if it is 0 or
// less). It returns nil after a shutdown caused by ctx, or the error that
// stopped it accepting, joined with any error from the hub's shutdown.
func (s *Server) serve(ctx context.Context, listen net.Listener) error {
// Clients outlive ctx until the hub has closed them, so the shutdown can
// tell them why; cancelling ctx itself would drop them without a word.
connCtx, cancelConns := context.WithCancel(context.WithoutCancel(ctx))
defer cancelConns()
stop := context.AfterFunc(ctx, func() {
listen.Close() //nolint:errcheck,gosec // Accept returns net.ErrClosed, which ends the loop
})
defer stop()
s.logger().Info("listening", "addr", listen.Addr().String())
acceptErr := s.accept(ctx, connCtx, listen)
if ctx.Err() != nil {
acceptErr = nil // cancelling ctx is how the server is stopped
}
listen.Close() //nolint:errcheck,gosec // closed already when ctx was cancelled
s.logger().Info("shutting down")
// No deadline if the shutdown timeout is 0 or less.
shutdownCtx, cancel := util.WithTimeout(
context.Background(), s.conf.Timeouts.ShutdownTimeout,
)
defer cancel()
errs := []error{acceptErr}
if err := s.hub.Shutdown(shutdownCtx); err != nil {
errs = append(errs, fmt.Errorf("websocket shutdown: %w", err))
}
return errors.Join(errs...)
}
// accept runs each connection on listen as a hub client, with connCtx, until
// ctx is cancelled (it returns ctx's error) or listen is closed some other way
// (it returns the error). Other accept errors are retried after a back off.
// Connections over the connection limit are turned away (see reject).
func (s *Server) accept(
ctx, connCtx context.Context, listen net.Listener,
) error {
limiter := s.connectionLimiter(ctx)
var delay time.Duration
for {
newConn, err := listen.Accept()
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
if errors.Is(err, net.ErrClosed) {
return fmt.Errorf("accept: %w", err)
}
// Back off and retry e.g. when out of file descriptors.
delay = min(max(2*delay, 5*time.Millisecond), time.Second)
s.logger().Warn("tcp accept failed", "error", err, "retry_in", delay)
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return ctx.Err()
}
}
delay = 0
if limiter != nil {
ip := remoteIP(newConn)
if allowed, _ := limiter.Allow(ip); !allowed {
s.logger().Warn("tcp connection rate limited", "ip", ip)
go reject(newConn, "rate limited")
continue
}
}
go s.hub.ServeTCP(connCtx, newConn)
}
}
// connectionLimiter returns a limiter of new connections per client IP for
// websocket.connection_limit, or nil if it is off. Its idle entries are
// evicted after rate_limits.idle_ttl until ctx is cancelled.
func (s *Server) connectionLimiter(ctx context.Context) middleware.Limiter {
rule := s.conf.WebSocket.ConnectionLimit
if !rule.Enabled || rule.PerSecond <= 0 {
return nil
}
return middleware.NewKeyedLimiter(
ctx, float64(rule.PerSecond), max(rule.Burst, 1), s.conf.RateLimits.IdleTTL,
)
}
// reject tells a client why it is being turned away, with an error envelope
// as the hub would send, then closes its connection.
func reject(conn net.Conn, reason string) {
defer conn.Close() //nolint:errcheck // turned away; nothing to do if closing fails
message, err := json.Marshal(websocket.Envelope{
Type: websocket.TypeError,
Error: reason,
})
if err != nil {
return
}
conn.SetWriteDeadline(time.Now().Add(rejectTimeout)) //nolint:errcheck,gosec // best effort
conn.Write(append(message, '\n')) //nolint:errcheck,gosec // best effort
}
// remoteIP returns the address of the connection's peer, or "" if it has no
// IP address.
func remoteIP(conn net.Conn) string {
addrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
if err != nil {
return ""
}
return addrPort.Addr().Unmap().String()
}