Documentation
¶
Overview ¶
Package relay defines the multiplexed tunnel protocol shared by the api (relay server) and the cli (system). The system holds a single outbound WebSocket to the api; hashicorp/yamux multiplexes many logical streams over it. Each stream begins with a JSON StreamHeader announcing its purpose, after which the stream carries kind-specific bytes.
Index ¶
- Constants
- Variables
- func ClientSession(conn net.Conn) (*yamux.Session, error)
- func EncodeActivity(active bool) []byte
- func EncodeData(p []byte) []byte
- func EncodeResize(cols, rows int) []byte
- func GenerateAgentKey() (*ecdh.PrivateKey, error)
- func NetConn(ctx context.Context, c *websocket.Conn) net.Conn
- func ReadClientHello(r io.Reader) ([]byte, error)
- func ReadRecord(r io.Reader) ([]byte, error)
- func ServerSession(conn net.Conn) (*yamux.Session, error)
- func WriteClientHello(w io.Writer, pub []byte) error
- func WriteHeader(w io.Writer, h StreamHeader) error
- func WriteRecord(w io.Writer, record []byte) error
- type PortEntry
- type PortInfo
- type ProjectInfo
- type ProvisionRequest
- type ProvisionResponse
- type Resize
- type SealedStream
- type Sealer
- type SessionKeys
- type StreamHeader
- type StreamKind
- type SystemInfo
- type TermFrame
Constants ¶
const ( // MaxFrameSize caps a single terminal frame payload (see ReadFrame). PTY // output is chunked at 32 KiB; browser input/paste is the larger case. // // ReadFrame allocates the declared length before reading a byte of payload, // and neither end limits how many streams the other may open, so this number // is the per-stream cost an adversarial peer can impose at will. 16 MiB made // a few hundred streams enough to exhaust an agent. 1 MiB is still orders of // magnitude above any real paste. MaxFrameSize = 1 << 20 // 1 MiB // MaxHeaderSize caps the newline-terminated JSON StreamHeader (see // ReadHeader). Headers are tiny; the only variable field is Cwd (a path). MaxHeaderSize = 64 << 10 // 64 KiB )
Size bounds on the two attacker-influenced read paths, so a compromised or buggy peer can't drive an unbounded allocation. Both ends of the tunnel read these, so the limits must be generous enough for real traffic (a large paste into a terminal, a long project path in a header) yet finite.
const ( // SealKeySize is the length of an X25519 public key, and of each derived // direction key. SealKeySize = 32 // MaxSealedRecord bounds a single sealed record on the wire. A record holds // one terminal frame plus the tag, so this tracks MaxFrameSize. MaxSealedRecord = MaxFrameSize + 5 + sealOverhead )
const ClientHelloSize = SealKeySize
ClientHelloSize is the length of the browser's opening record: its ephemeral public key, sent in the clear because it is public by construction.
const MaxTunnelStreams = 128
MaxTunnelStreams caps how many logical streams either end will serve on one tunnel at a time.
yamux itself has no such limit, so without this a peer could open streams until the other side ran out of memory — each one costing a receive window plus whatever the stream's handler allocates (a PTY, a TCP connection to a local service, a frame buffer). The number is far above real use: a busy workspace is a handful of terminals and one proxied port.
Variables ¶
var ErrSealFailed = errors.New("sealed record failed authentication")
ErrSealFailed is returned when a record does not authenticate. It is deliberately opaque: distinguishing "wrong key" from "tampered" tells an attacker which of the two they achieved.
Functions ¶
func ClientSession ¶
ClientSession creates the api-side yamux session (opens streams to the system).
func EncodeActivity ¶
EncodeActivity encodes a frame reporting whether a foreground process is running on the PTY (agent -> browser only).
func EncodeResize ¶
EncodeResize encodes a terminal resize frame.
func GenerateAgentKey ¶
func GenerateAgentKey() (*ecdh.PrivateKey, error)
GenerateAgentKey returns a new X25519 private key for an agent.
func NetConn ¶
NetConn wraps a coder/websocket connection as a net.Conn suitable for running yamux over it. Binary message framing is used so yamux's byte stream passes through untouched.
func ReadClientHello ¶
ReadClientHello reads the browser's ephemeral public key.
A hello of the wrong length is refused rather than padded or truncated: the only thing that produces one is a peer that does not speak this protocol, and deriving a key from a malformed input would turn a version mismatch into a silent failure much later.
func ReadRecord ¶
ReadRecord reads a single length-prefixed record.
func ServerSession ¶
ServerSession creates the system-side yamux session (accepts streams opened by the api).
func WriteClientHello ¶
WriteClientHello sends the browser's ephemeral public key as the stream's first record. It is unsealed — there is no shared key yet, and the value is public anyway.
func WriteHeader ¶
func WriteHeader(w io.Writer, h StreamHeader) error
WriteHeader encodes h as a single newline-terminated JSON line on w.
func WriteRecord ¶
WriteRecord writes a length-prefixed record.
The length prefix exists for the yamux side, which is a byte stream. Over the browser WebSocket each record is already one binary message, so the server adds this prefix in one direction and strips it in the other — which is the whole of its involvement in terminal traffic.
Types ¶
type PortInfo ¶
type PortInfo struct {
Project string `json:"project"`
Port int `json:"port"`
Label string `json:"label"`
}
PortInfo is a single configured exposed port, labelled with its project name (GET /system/ports). Name-only; for the read-only ports list.
type ProjectInfo ¶
type ProjectInfo struct {
ID string `json:"id"`
Name string `json:"name"`
RootDir string `json:"root_dir"`
Ports []PortEntry `json:"ports"`
}
ProjectInfo is a project on a system with its exposed ports, for the system's own management view (GET /system/projects). Record ids are carried so the CLI can edit/delete specific records.
type ProvisionRequest ¶
type ProvisionRequest struct {
Email string `json:"email"`
Password string `json:"password"`
ClientID string `json:"clientId"`
Hostname string `json:"hostname"`
Name string `json:"name"` // optional label; server defaults to "System N"
}
ProvisionRequest is the body of POST /system/provision: account credentials plus the calling system's stable client id and display info.
type ProvisionResponse ¶
type ProvisionResponse struct {
SystemID string `json:"systemId"`
Token string `json:"token"`
Name string `json:"name"`
}
ProvisionResponse is returned on a successful provision: the system id, a freshly-minted pairing token (shown once, never persisted server-side), and the resolved display name.
type SealedStream ¶
type SealedStream struct {
// contains filtered or unexported fields
}
SealedStream carries terminal frames over an ordered byte stream, sealed.
Each direction has its own key and its own counter, so the two never share a nonce and a record cannot be replayed back at its sender. One of these belongs to one connection: a terminal session the browser reattaches to gets a fresh ephemeral key each time, so a session with two live connections has two independent SealedStreams and no shared crypto state between them.
func NewSealedStream ¶
NewSealedStream builds the transport for one connection. sendKey seals what this side writes; recvKey opens what it reads.
func (*SealedStream) ReadFrame ¶
func (s *SealedStream) ReadFrame() (TermFrame, error)
ReadFrame reads one record, opens it, and decodes the frame inside.
func (*SealedStream) WriteFrame ¶
func (s *SealedStream) WriteFrame(frame []byte) error
WriteFrame seals an encoded frame and writes it as one record.
type Sealer ¶
type Sealer struct {
// contains filtered or unexported fields
}
Sealer seals and opens records for one direction of one stream.
Nonces are a counter, never random: a 96-bit random nonce has a birthday bound that a long-lived terminal could plausibly approach, whereas a counter cannot repeat before it overflows. Each direction has its own Sealer, so the two never share a counter under the same key.
type SessionKeys ¶
SessionKeys are the two directional keys derived from one agreement. Each direction has its own key and its own counter, so a record cannot be replayed back at its sender.
func DeriveSessionKeys ¶
func DeriveSessionKeys(priv *ecdh.PrivateKey, peerPub []byte, sessionID string) (*SessionKeys, error)
DeriveSessionKeys performs the X25519 agreement and expands it into the two directional keys.
sessionID is bound in as the HKDF salt so two streams that somehow agreed on the same secret still derive different keys, and a record captured from one stream cannot be replayed into another.
type StreamHeader ¶
type StreamHeader struct {
Kind StreamKind `json:"kind"`
Port int `json:"port,omitempty"` // for KindProxy: local TCP port to dial
Cols int `json:"cols,omitempty"` // for KindTerminal: initial columns
Rows int `json:"rows,omitempty"` // for KindTerminal: initial rows
Cwd string `json:"cwd,omitempty"` // for KindTerminal: working directory
SessionID string `json:"session_id,omitempty"` // for KindTerminal: stable tab identity
}
StreamHeader is the first message written on every yamux stream. The api (yamux client) opens a stream and writes this; the system (yamux server) reads it to decide how to handle the stream.
func ReadHeader ¶
ReadHeader reads a single newline-terminated JSON header from r. The returned bufio.Reader MUST be used for any subsequent reads on the stream, since it may have buffered bytes past the header's newline.
type StreamKind ¶
type StreamKind string
StreamKind identifies what a newly opened yamux stream is for.
const ( // KindTerminal carries interactive PTY traffic using terminal frames. KindTerminal StreamKind = "terminal" // KindProxy carries a raw TCP proxy to a local port on the system host. KindProxy StreamKind = "proxy" // KindListPorts asks the system to report its currently-listening loopback // TCP ports as a JSON array of ints, then the stream is closed. KindListPorts StreamKind = "listports" // KindPing is a debug stream that echoes bytes back (used in phase 3). KindPing StreamKind = "ping" // KindShutdown asks the system agent to shut down gracefully (stop the CLI). KindShutdown StreamKind = "shutdown" // KindEvent is a relay→system nudge that upstream data (its projects, ports, // or its own name) changed — e.g. edited in the web UI — so the system should // refetch. Carries no payload; the stream is opened and immediately closed. KindEvent StreamKind = "event" )
type SystemInfo ¶
type SystemInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Online bool `json:"online"`
IPAddr string `json:"ip_addr"`
}
SystemInfo is a system's own display info (GET /system/info).
type TermFrame ¶
type TermFrame struct {
Resize *Resize // non-nil for resize frames
Data []byte // non-nil for data frames
Active *bool // non-nil for activity frames (system -> browser)
}
TermFrame is a decoded terminal frame.
func DecodeFrame ¶
DecodeFrame decodes a single terminal frame from the plaintext of a record.
The length is validated against the buffer rather than trusted, because this runs on plaintext that has already been authenticated but was still authored by the peer: a frame claiming to be longer than it is must be an error, not a slice past the end.