Documentation
¶
Overview ¶
Package wsclient is the WebSocket transport for connections to the Alpacon backhaul, packaged as a leaf so that modules outside this repository, such as the in-cluster alpamon-kube agent, can use it without compiling the host agent.
A Client dials with the Authorization header the backhaul expects (id="...", key="..."), re-arms a read timeout before every read, and reconnects whenever the connection drops. Exponential backoff with jitter paces the attempts, including the redial after a connection that lasted less than MinBackoff, so a peer that accepts the handshake and drops it cannot draw a tight loop out of a fleet of agents. Everything it needs arrives through Config; nothing here reads alpamon's global settings. It reports connects, drops and retries through the hooks on Config instead of logging, so the caller decides where those go.
Ownership ¶
A Client owns its connection and never exposes it. Run is the only goroutine that reads, writes are serialized, and Shutdown and Reconnect only signal the read loop, so gorilla/websocket's rule of one concurrent reader and one concurrent writer holds by construction. A caller that needs a connection of its own, for example to wrap in tunnel.WebSocketConn for smux, uses Dial and takes on that responsibility itself.
Dependencies ¶
This package may import the standard library, github.com/gorilla/websocket, and alpamon's pkg/tunnel and pkg/version, and nothing else. The leaf-guard job in .github/workflows/build-and-test.yml fails the build if its dependency graph reaches any other package.
That list is the ceiling, not an inventory. What ships imports pkg/version, for the default User-Agent, and gorilla/websocket; pkg/tunnel is allowed for the caller in Ownership above, who wraps what Dial returns, and is not reached from here. The list is repeated in the job's own ALLOWED variable, and the job is the one that decides.
Relation to pkg/runner ¶
pkg/runner still carries WebSocket clients of its own, and the same numbers are written out in both places: the 35-minute read timeout, the 5s and 60s reconnect bounds, the Authorization format, and a close-frame-then-drain- then-close sequence. The schedules have already drifted, with internal/retry drawing its jitter factor from [0.5, 1.5) and this package from [1.0, 1.5).
They are meant to converge here rather than be kept in step by hand: pkg/runner may import this package, and issue #452, which this package avoids by construction and pkg/runner still has, is the reason to. Until that lands, a change to either one is a change both copies need.
Index ¶
Constants ¶
const ( // DefaultHandshakeTimeout bounds the opening handshake in DefaultDialer. DefaultHandshakeTimeout = 30 * time.Second // DefaultReadTimeout is how long a read may wait for a frame before the // connection is treated as dead. The Alpacon backhaul pings well inside it. DefaultReadTimeout = 35 * time.Minute // DefaultWriteTimeout bounds each WriteMessage or WriteJSON call, so a // stalled peer cannot hold up every other writer. DefaultWriteTimeout = 10 * time.Second // DefaultReadLimit is the largest inbound message accepted, in bytes. DefaultReadLimit = 10 << 20 // DefaultMinBackoff and DefaultMaxBackoff bound the reconnect schedule. DefaultMinBackoff = 5 * time.Second DefaultMaxBackoff = 60 * time.Second )
Defaults applied to zero-valued Config fields.
Variables ¶
var ( // ErrNotConnected is returned by a write made while no connection is up, // for example between a drop and the reconnect that follows it. ErrNotConnected = errors.New("wsclient: not connected") // ErrAlreadyRunning is returned by Run on a Client whose Run has already // been called. A Client is single-use. ErrAlreadyRunning = errors.New("wsclient: Run has already been called") )
Functions ¶
func DefaultDialer ¶
DefaultDialer returns the dialer a nil Config.Dialer stands for: a DefaultHandshakeTimeout handshake, the proxy named by HTTPS_PROXY, HTTP_PROXY and NO_PROXY, and the system roots for TLS. Each call returns a new dialer, which the caller may adjust before putting it in a Config.
func Dial ¶
Dial opens one connection and hands it over: the caller owns it outright, including reading, writing, deadlines and closing, and nothing reconnects it. It suits a connection with its own lifecycle, such as a tunnel data plane wrapped in tunnel.WebSocketConn for smux.
Dial uses URL, ID, Key, Origin, UserAgent, Header, Dialer and ReadLimit from cfg, and ignores the timeout, backoff and hook fields. When the server rejects the handshake, the error wraps websocket.ErrBadHandshake and the response is returned alongside it, with the Authorization header stripped from the request hanging off it so that logging the response cannot spill the credential.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client keeps one WebSocket connection up: it dials, reconnects with jittered backoff whenever the connection drops, and hands every inbound message to a Handler.
All methods are safe for concurrent use, and the connection is never exposed, so a caller cannot break gorilla/websocket's one-reader, one-writer rule by accident. The Ownership section of the package doc says how that is arranged; conn.go holds the arrangement.
func (*Client) Connected ¶
Connected reports whether a connection is up. It is a snapshot: the connection may drop the moment after it returns true.
func (*Client) Done ¶
func (c *Client) Done() <-chan struct{}
Done is closed once Run has returned. It never closes if Run is never called.
func (*Client) Reconnect ¶
func (c *Client) Reconnect()
Reconnect drops the live connection so that Run dials a fresh one, as when the server asks the agent to reconnect. While no connection is up it does nothing, since Run is already dialing. It does not block.
func (*Client) Run ¶
Run dials, then feeds every inbound message to h until Shutdown is called, ctx is done, or h returns an error, reconnecting whenever the connection drops. It keeps trying for as long as it runs; OnRetry reports every wait, so a caller that wants to give up can call Shutdown.
The backoff paces attempts, not just failed dials. Anything that ends a connection short of MinBackoff is followed by the same jittered wait a failed dial gets, whether the dial was refused, the peer hung up, or a write failed. Only a connection that lasted at least MinBackoff, or a close the caller asked for through Reconnect, redials at once and restarts the schedule; Shutdown and a done ctx end Run instead. Without that, a peer that accepts the handshake and drops it can drive a redial loop across a whole fleet.
Run returns nil after Shutdown, ctx.Err() once ctx is done, and h's error when h stopped it. The connection is closed before Run returns. A Client runs once: a second call returns ErrAlreadyRunning.
It returns that promptly as far as the caller's own code lets it. h, the three hooks and the dialer's dial hook all run on Run's goroutine, so one that blocks holds Run there; a dial hook is the one to watch, because it can be waiting on a socket that does not exist yet and so cannot be freed.
func (*Client) Shutdown ¶
func (c *Client) Shutdown()
Shutdown makes Run close the connection and return. It does not wait; Done does. Calling it more than once, before Run, or after Run returned is safe.
func (*Client) WriteMessage ¶
WriteMessage sends one data frame on the live connection. messageType must be websocket.TextMessage or websocket.BinaryMessage: control frames belong to the Client. It returns ErrNotConnected while no connection is up, and never queues a frame for a later connection. A failed write leaves the connection unable to send, so it also makes Run replace the connection.
type Config ¶
type Config struct {
// URL is the full endpoint, e.g. wss://backhaul.example.com/ws/servers/backhaul/.
// http and https are accepted and mapped to ws and wss.
//
// It must come from trusted configuration. The credential below is sent
// to whatever host it names, and nothing here pins that host, so a URL
// taken from a server response or from user input hands the agent's key
// to that host. A ws:// or http:// URL sends the credential in the
// clear, which is for a local endpoint or a mesh that supplies its own
// transport security, never the open internet.
URL string
// ID and Key are sent as Authorization: id="<ID>", key="<Key>".
ID string
Key string
// Origin, when non-empty, is sent as the Origin header.
Origin string
// UserAgent is sent as the User-Agent header. Empty means
// "alpamon/<version>", which is only right for alpamon itself: any other
// agent should name itself here.
UserAgent string
// Header carries extra request headers. Authorization and User-Agent
// always come from the fields above, as does Origin when it is set.
Header http.Header
// Dialer opens the connection. Nil means DefaultDialer(). A non-nil
// dialer is used as given except for four things. A zero
// HandshakeTimeout becomes DefaultHandshakeTimeout, because
// gorilla/websocket stops watching the context once the socket is up and
// an unbounded handshake would hang with no signal at all; a negative one
// is rejected. TLSClientConfig and Subprotocols are copied, so that a
// later edit to the tls.Config's own fields, InsecureSkipVerify above
// all, cannot change how a running client verifies certificates, and a
// later edit to the subprotocol list cannot change what it negotiates.
// That copy is one level deep: RootCAs, ClientCAs, Certificates and the
// verification callbacks are pointers the caller keeps a hold of, so
// adding a certificate to a pool still reaches the next reconnect. That
// is left as it is on purpose, since snapshotting a pool would quietly
// stop a caller who rotates trust through one, but it means those are
// edits to a running client rather than to a copy of its config. Start
// from DefaultDialer() to keep its proxy settings.
//
// The fourth is the dial hooks, which are wrapped so that Shutdown and a
// done context can free a socket the handshake is still waiting on. A
// hook has to return that socket first: NetDialContext is handed a
// context that ends with the client's, but NetDial takes none and is
// adapted to the wrapper, so nothing can interrupt it before it returns.
// Prefer NetDialContext, and give a NetDial a timeout of its own.
Dialer *websocket.Dialer
// ReadLimit is the largest inbound message accepted, in bytes: its
// frames' payloads summed as they arrive, before any decompression. Zero
// means DefaultReadLimit; a negative value removes the limit.
//
// It bounds memory only while compression is off, which is the default:
// gorilla/websocket counts the compressed bytes against the limit and
// then reads the decompressed stream whole, so a caller who sets
// EnableCompression on their own Dialer is bounding the frame, not the
// allocation it expands into. Nothing here can bound the second.
ReadLimit int64
// ReadTimeout is re-armed before every read. Zero means DefaultReadTimeout.
ReadTimeout time.Duration
// WriteTimeout bounds each WriteMessage and WriteJSON call. Control frames
// the client sends itself, pongs and close frames, have a short fixed
// bound of their own. Zero means DefaultWriteTimeout.
WriteTimeout time.Duration
// MinBackoff and MaxBackoff bound the wait between reconnect attempts.
// Each wait is drawn uniformly from the doubling base up to half again
// as much, or up to MaxBackoff where that would pass it. Zero means
// DefaultMinBackoff and DefaultMaxBackoff.
//
// The two ends are not symmetric. MinBackoff is a floor that waits
// actually sit on, while MaxBackoff is one they approach: the base stops
// at two thirds of it so the jitter still has somewhere to go, which is
// what keeps a fleet that has been retrying for a while from redialing
// on a single tick. A settled schedule lands in [2/3 MaxBackoff,
// MaxBackoff), not on MaxBackoff itself. Setting the two equal asks for
// a fixed wait and gets one.
MinBackoff time.Duration
MaxBackoff time.Duration
// Rand returns the value in [0, 1) the jitter factor is drawn from. Nil
// means math/rand/v2. It is here for a caller that wants its own
// reconnect timing to be reproducible in its own tests, which a module
// outside this repository cannot arrange any other way.
Rand func() float64
// OnConnect runs after every successful dial. OnDisconnect runs once for
// every connection that OnConnect announced; err is nil when the client
// closed a healthy connection because Shutdown, Reconnect or the Run
// context asked it to, and is otherwise what ended the connection: a
// failed read or write, the peer's close, or the handler's error. OnRetry
// runs before each wait between connection attempts, whether the last dial
// failed or a connection ended before it proved itself; err is what caused
// the wait, and attempt counts from 1, restarting after a connection that
// worked. All three run on the Run goroutine, so a slow hook delays
// reading and reconnecting. They may call any Client method except Run.
OnConnect func()
OnDisconnect func(err error)
OnRetry func(attempt int, delay time.Duration, err error)
}
Config is what a caller injects to build a Client or Dial. Only URL, ID and Key are required; every other zero value means the documented default.