Added websocket hub for handling websocket clients, with a wsserve command
which runs a plain TCP server for receiving websocket hub clients, with server config, CSRF and rate limit middleware. - internal/websocket: Hub pushing messages to WebSocket clients (ServeHTTP, example.com/ws upgrade with optional CSRF check) and plain TCP clients (ServeTCP, newline-delimited JSON envelopes); ping/pong keepalive, per-client message rate limit, slow consumer handling, graceful Shutdown, debug logging of messages sent and received - internal/websocket/tcpserver: standalone TCP server for a hub, with accept back-off, a per-IP connection limit (server.websocket.connection_limit) and graceful shutdown - internal/cli/wsserve: `wsserve` command running the TCP server, with --listen/--host/--port overrides - internal/middleware: keyed token bucket rate limiter, real client IP from trusted proxies - internal/httpx: RFC 9457 problem responses, request IDs in context - internal/config: server section (host, port, shutdown timeout, rate limiter idle TTL, websocket); util.ByteSize settings (e.g. "32KiB"); lists of sections as single keys; empty lists stored as nil; isExplicitlySet for printLoadedConfig; JobSearchConfigSource renamed to JSCSourcesSource; TestSections checks every section's methods - internal/util: ByteSize, parsed and formatted with go-humanize
This commit is contained in:
1 parent
803a8929e6
commit
c9f42b62ad
42 files changed
+3619
-57
No files matched your search
@@ -0,0 +1,261 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
ws "github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// Plain TCP clients join the hub as if they were WebSocket clients. Instead
|
||||
// of WebSocket frames, each message is one line of JSON (an Envelope)
|
||||
// terminated by "\n" in both directions, so a client can be as simple as
|
||||
// netcat:
|
||||
//
|
||||
// $ nc localhost 9393
|
||||
// {"type":"welcome","payload":{"clientId":"..."}}
|
||||
// {"type":"ping","id":"1"}
|
||||
// {"type":"pong","id":"1","payload":{"time":"..."}}
|
||||
//
|
||||
// A trailing "\r" is ignored and blank lines are skipped. A line longer than
|
||||
// the websocket max_message_size closes the connection, as an oversized frame
|
||||
// does. When the server closes a connection (shutdown, slow consumer, a line
|
||||
// too long) it sends an error envelope with the reason first.
|
||||
//
|
||||
// Plain TCP has no ping frames, so liveness uses TCP keepalives probing at the
|
||||
// ping interval; Ping itself writes a blank line, which clients ignore, so a
|
||||
// dead peer is found when a write fails.
|
||||
//
|
||||
// There is no HTTP request, so there is no CSRF or Origin check and no proxy
|
||||
// handling: the client's address is the TCP peer. Only listen where clients
|
||||
// are trusted (e.g. localhost), or wrap the listener (e.g. with PROXY protocol
|
||||
// or TLS) before passing its connections to ServeTCP. ServeTCP doesn't limit
|
||||
// connections; package tcpserver applies websocket.connection_limit.
|
||||
|
||||
// ServeTCP runs a plain TCP connection as a hub client until it closes, as
|
||||
// ServeHTTP does for a WebSocket upgrade. Cancelling ctx drops the client
|
||||
// without a message; use Shutdown to close clients gracefully. The
|
||||
// connection is closed when ServeTCP returns.
|
||||
func (h *Hub) ServeTCP(ctx context.Context, newConn net.Conn) {
|
||||
if tcpSocket, ok := newConn.(*net.TCPConn); ok && h.conf.PingInterval > 0 {
|
||||
tcpSocket.SetKeepAliveConfig(net.KeepAliveConfig{ //nolint:errcheck,gosec // best effort
|
||||
Enable: true,
|
||||
Idle: h.conf.PingInterval,
|
||||
Interval: h.conf.PingInterval,
|
||||
Count: 3,
|
||||
})
|
||||
}
|
||||
|
||||
ip := ""
|
||||
if addrPort, err := netip.ParseAddrPort(newConn.RemoteAddr().String()); err == nil {
|
||||
ip = addrPort.Addr().Unmap().String()
|
||||
}
|
||||
|
||||
conn := newTCPConn(newConn, h.maxMessageSize(), h.conf.WriteTimeout)
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
client := newClient(h, conn, ip, cancel)
|
||||
|
||||
if !h.register(client) {
|
||||
cancel()
|
||||
conn.Close(ws.StatusGoingAway, "server shutting down") //nolint:errcheck,gosec // turned away
|
||||
return
|
||||
}
|
||||
defer h.unregister(client)
|
||||
|
||||
h.log.Info("client connected", "client", client.id, "ip", ip, "transport", "tcp")
|
||||
client.run(ctx)
|
||||
conn.CloseNow() //nolint:errcheck,gosec // finished with; it may already be closed
|
||||
h.log.Info("client disconnected", "client", client.id, "ip", ip, "transport", "tcp")
|
||||
}
|
||||
|
||||
// ---------- tcpConn ----------
|
||||
|
||||
// tcpConn adapts a net.Conn carrying newline-delimited JSON to the msgConn a
|
||||
// client uses. Read is only called from the client's read loop; writes may
|
||||
// come from the write loop, Ping and Close concurrently, so they are
|
||||
// serialised.
|
||||
type tcpConn struct {
|
||||
conn net.Conn
|
||||
scan *bufio.Scanner
|
||||
closeTimeout time.Duration // caps Close's message; 0 or less for none
|
||||
|
||||
wm sync.Mutex // serialises writes
|
||||
|
||||
closeOnce sync.Once // records the first close
|
||||
closed chan struct{} // closed by the first Close or CloseNow
|
||||
closeErr ws.CloseError // why the connection was closed
|
||||
}
|
||||
|
||||
// newTCPConn wraps conn. maxLine caps the length of a line read
|
||||
// (defaultMaxMessageSize if 0 or less) and closeTimeout how long Close spends
|
||||
// sending its message.
|
||||
func newTCPConn(
|
||||
conn net.Conn, maxLine int, closeTimeout time.Duration,
|
||||
) *tcpConn {
|
||||
if maxLine <= 0 {
|
||||
maxLine = defaultMaxMessageSize
|
||||
}
|
||||
|
||||
scan := bufio.NewScanner(conn)
|
||||
// The buffer must hold the line and its "\n".
|
||||
scan.Buffer(make([]byte, 0, min(maxLine+1, 4096)), maxLine+1)
|
||||
|
||||
return &tcpConn{
|
||||
conn: conn,
|
||||
scan: scan,
|
||||
closeTimeout: closeTimeout,
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Read returns the next non-blank line as a text message. Cancelling ctx
|
||||
// interrupts it. The end of the stream is reported as a normal closure, and a
|
||||
// read after Close as the close status Close sent, as a *websocket.Conn
|
||||
// reports them, so the client treats them as a finished close handshake.
|
||||
func (t *tcpConn) Read(ctx context.Context) (ws.MessageType, []byte, error) {
|
||||
stop := context.AfterFunc(ctx, func() {
|
||||
t.conn.SetReadDeadline(time.Now()) //nolint:errcheck,gosec // only interrupts the read
|
||||
})
|
||||
defer stop()
|
||||
|
||||
for t.scan.Scan() {
|
||||
line := strings.TrimSuffix(t.scan.Text(), "\r")
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
return ws.MessageText, []byte(line), nil
|
||||
}
|
||||
|
||||
err := t.scan.Err()
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
return 0, nil, ctx.Err()
|
||||
case t.isClosed():
|
||||
return 0, nil, t.closeErr
|
||||
case err == nil || errors.Is(err, io.EOF):
|
||||
return 0, nil, ws.CloseError{
|
||||
Code: ws.StatusNormalClosure,
|
||||
Reason: "connection closed",
|
||||
}
|
||||
case errors.Is(err, bufio.ErrTooLong):
|
||||
// As a *websocket.Conn closes with 1009 for an oversized message.
|
||||
// The client may not see the reason: closing with its unread data
|
||||
// still buffered makes the connection reset.
|
||||
t.Close(ws.StatusMessageTooBig, "message too long") //nolint:errcheck,gosec // dropped anyway
|
||||
return 0, nil, t.closeErr
|
||||
}
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
// Write sends payload as one line. Only text messages are supported.
|
||||
func (t *tcpConn) Write(
|
||||
ctx context.Context, messageType ws.MessageType, payload []byte,
|
||||
) error {
|
||||
if messageType != ws.MessageText {
|
||||
return errUnsupportedData
|
||||
}
|
||||
line := make([]byte, 0, len(payload)+1)
|
||||
line = append(append(line, payload...), '\n')
|
||||
return t.write(ctx, line)
|
||||
}
|
||||
|
||||
// Ping writes a blank line, which clients ignore, so a dead peer shows up as a
|
||||
// failed write. TCP keepalives (see ServeTCP) do the rest.
|
||||
func (t *tcpConn) Ping(ctx context.Context) error {
|
||||
return t.write(ctx, []byte("\n"))
|
||||
}
|
||||
|
||||
// Close sends an error envelope giving the reason, then closes the
|
||||
// connection. Only the first Close or CloseNow does anything; later calls
|
||||
// return nil. A CloseNow while Close is sending its message cuts it short.
|
||||
func (t *tcpConn) Close(code ws.StatusCode, reason string) error {
|
||||
if !t.markClosed(ws.CloseError{Code: code, Reason: reason}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if t.closeTimeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, t.closeTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
message, err := json.Marshal(Envelope{
|
||||
Type: TypeError,
|
||||
Error: reason,
|
||||
})
|
||||
if err == nil {
|
||||
t.write(ctx, append(message, '\n')) //nolint:errcheck,gosec // best effort, closing anyway
|
||||
}
|
||||
|
||||
if err := t.conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseNow closes the connection without a message. It doesn't wait for a
|
||||
// Close that is sending its message.
|
||||
func (t *tcpConn) CloseNow() error {
|
||||
t.markClosed(ws.CloseError{Code: ws.StatusAbnormalClosure})
|
||||
if err := t.conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markClosed records why the connection is being closed and reports whether
|
||||
// this is the first close. It never blocks, so a Close sending its message
|
||||
// can't hold up CloseNow.
|
||||
func (t *tcpConn) markClosed(why ws.CloseError) bool {
|
||||
first := false
|
||||
t.closeOnce.Do(func() {
|
||||
t.closeErr = why
|
||||
close(t.closed)
|
||||
first = true
|
||||
})
|
||||
return first
|
||||
}
|
||||
|
||||
// isClosed reports whether Close or CloseNow has been called.
|
||||
func (t *tcpConn) isClosed() bool {
|
||||
select {
|
||||
case <-t.closed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// write writes data whole, giving up at ctx's deadline or when ctx is
|
||||
// cancelled.
|
||||
func (t *tcpConn) write(ctx context.Context, data []byte) error {
|
||||
t.wm.Lock()
|
||||
defer t.wm.Unlock()
|
||||
|
||||
deadline, _ := ctx.Deadline() // zero means none
|
||||
if err := t.conn.SetWriteDeadline(deadline); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stop := context.AfterFunc(ctx, func() {
|
||||
t.conn.SetWriteDeadline(time.Now()) //nolint:errcheck,gosec // only interrupts the write
|
||||
})
|
||||
defer stop()
|
||||
|
||||
_, err := t.conn.Write(data)
|
||||
if err != nil && ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
Reference in new issue
Block a user