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 AcceptStreamFence(h StreamHeader, acceptedAt time.Time) (time.Time, error)
- 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 Fingerprint(pub []byte) string
- func GenerateAgentKey() (*ecdh.PrivateKey, error)
- func GenerateServerSalt() ([]byte, error)
- func IsStreamFenceExpired(err error) bool
- 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 ReadRecordInto(r io.Reader, buf []byte) ([]byte, error)
- func ReadServerHello(r io.Reader) ([]byte, error)
- func ServerSession(conn net.Conn) (*yamux.Session, error)
- func StreamKindRequiresFence(kind StreamKind) bool
- func ValidPort(p int) bool
- func ValidTerminalSize(cols, rows int) bool
- func ValidateActionAck(h StreamHeader, ack ActionAck) error
- func ValidateStreamFence(h StreamHeader, now time.Time) error
- func ValidateStreamFenceDeadline(deadline, now time.Time) error
- func WriteActionAck(w io.Writer, ack ActionAck) 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
- func WriteServerHello(w io.Writer, salt []byte) error
- type ActionAck
- type ActionAckStatus
- type DevicePollRequest
- type DevicePollResponse
- type DeviceStartRequest
- type DeviceStartResponse
- type PortEntry
- type PortInfo
- type ProjectInfo
- type ProvisionResponse
- type Resize
- type SealedStream
- type Sealer
- type SessionKeys
- type StreamHeader
- type StreamKind
- type SystemInfo
- type TermFrame
Constants ¶
const ( // DeviceStatusPending means the code is live but not yet approved. DeviceStatusPending = "pending" // DeviceStatusExpired means the codes expired unapproved; start over. DeviceStatusExpired = "expired" // DeviceStatusApproved means the user approved the code; the pairing // fields are set. DeviceStatusApproved = "approved" )
Device flow states reported in DevicePollResponse.Status.
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 ( // MinTerminalDim and MaxTerminalDim bound a terminal's columns and rows, on // the opening StreamHeader and on every resize frame after it. // // The upper bound is not about what a display could plausibly be. It is // what keeps the dimensions inside the uint16 that TIOCSWINSZ takes: the // agent casts to uint16 when it calls pty.Setsize, so a bound above 65535 // would wrap silently and set a window nothing asked for. MinTerminalDim = 1 MaxTerminalDim = 1000 // MinPort and MaxPort bound a TCP port number. MinPort = 1 MaxPort = 65535 // PublicKeyHeader carries the agent's sealing public key on the tunnel // handshake — every connect, rather than once at pairing, so an agent whose // key was regenerated starts working again by reconnecting instead of being // re-paired. It is the invariant with the quietest failure of the three: // change it on one side and the relay simply never calls SetSystemPubKey, // browsers keep sealing against a stale key, and terminals stop opening // with no error anywhere. PublicKeyHeader = "X-Ormos-Public-Key" // StreamFenceVersionHeader negotiates agent-enforced action behavior on the // tunnel handshake. Its absence is reserved for the already-released v0.1.5 // wire format; current agents always advertise an explicit supported version. StreamFenceVersionHeader = "X-Ormos-Stream-Fence-Version" // LegacyV0 is a backend capability sentinel for header absence, never a // value an agent may send. Explicit "0" is unsupported. Version 1 introduced // agent-enforced action fences; version 2 adds the terminal shutdown // acknowledgment. This agent advertises only the current v2 capability. StreamFenceVersionLegacyV0 = "" StreamFenceVersionV1 = "1" StreamFenceVersionV2 = "2" StreamFenceVersion = StreamFenceVersionV2 )
Protocol invariants both binaries must agree on. They live here, in the one package the agent and the relay both import, because that is the only place they cannot drift: a bound spelled as a literal on each side is two bounds, and nothing fails loudly when they stop matching.
const ( // SealKeySize is the length of an X25519 public key, and of each derived // direction key. SealKeySize = 32 // SealSaltSize is the length of the per-connection server salt (see // GenerateServerSalt). SealSaltSize = 32 // MaxSealedRecord bounds a single sealed record on the wire. A record holds // one terminal frame plus the tag, so this tracks MaxFrameSize. frameHeaderSize // is the single spelling of the frame's tag+length prefix (see protocol.go). MaxSealedRecord = MaxFrameSize + frameHeaderSize + sealOverhead )
const ( MaxStreamWindow = 4 << 20 MaxTunnelWindowBytes = MaxStreamWindow * MaxTunnelStreams )
MaxStreamWindow is the per-stream receive window both ends of the tunnel advertise, and MaxTunnelWindowBytes is what that costs across every stream a peer may open at once. The second is the number worth watching: raising either factor raises the memory an adversarial peer can pin.
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.
const ServerHelloSize = SealSaltSize
ServerHelloSize is the length of the agent's reply to the client hello: the per-connection server salt, sent in the clear because it is a public random value that reveals nothing on its own.
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 AcceptStreamFence ¶ added in v0.1.6
AcceptStreamFence validates a header once, when it is accepted, and converts its wall-clock NotAfter into a deadline attached to acceptedAt's monotonic clock. Every later action check must carry this returned deadline; rebuilding time.UnixMilli later would let an NTP/manual wall-clock jump extend or shrink an already-accepted capability. The wall remaining duration is clamped before Add, while the same one-minute skew tolerance remains enforced below.
The returned deadline is valid and required even when the error is non-nil, which is deliberate and not the usual Go convention. A refused shutdown still has to answer the relay, and writeShutdownAck bounds that refusal ACK with this deadline: for an expired fence the clamp yields exactly acceptedAt, which is what routes the ACK down the non-success branch and grants it the full write timeout to report a truthful terminal result. Discarding the deadline on the error path would silently shorten that window to nothing.
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 Fingerprint ¶ added in v0.1.2
Fingerprint renders a short, human-comparable base32 fingerprint of an agent public key for out-of-band verification: the agent prints it on startup and the app pins it, so a user can read one against the other and catch a relay that swapped the key. It is the RFC 4648 base32 of the first 10 bytes of SHA-256(pub) — 80 bits — grouped in fours. seal.ts's fingerprint() must produce the identical string.
func GenerateAgentKey ¶
func GenerateAgentKey() (*ecdh.PrivateKey, error)
GenerateAgentKey returns a new X25519 private key for an agent.
func GenerateServerSalt ¶ added in v0.1.2
GenerateServerSalt returns a fresh per-connection salt. A new one every connection is what makes the counter-nonce scheme safe across reattaches: the salt goes into the key schedule (DeriveSessionKeys), so even a client that reuses its ephemeral key lands in a distinct key/nonce space each time.
func IsStreamFenceExpired ¶ added in v0.1.6
IsStreamFenceExpired distinguishes an expired otherwise-shaped fence from a malformed refusal so action protocols can report a stable terminal status.
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 into a fresh buffer. Use it wherever the record outlives the next read — the handshake paths, which keep the hello they read.
func ReadRecordInto ¶ added in v0.1.4
ReadRecordInto reads a single length-prefixed record, reusing buf when it is big enough and returning the buffer it used.
The returned slice aliases buf, so it is valid only until the next call with the same buffer. That suits a pump that hands each record straight to a Write and is provably done with it. It saves one allocation per record, not the record's cost: opening it still allocates the plaintext, which is what makes SealedStream.ReadFrame safe to build on this at all.
func ReadServerHello ¶ added in v0.1.2
ReadServerHello reads the agent's per-connection salt. A reply of the wrong length is refused rather than used, for the same reason ReadClientHello refuses a malformed hello: it only comes from a peer that does not speak this protocol, and mixing nonsense into the schedule turns a version mismatch into a confusing failure much later.
func ServerSession ¶
ServerSession creates the system-side yamux session (accepts streams opened by the api).
func StreamKindRequiresFence ¶ added in v0.1.6
func StreamKindRequiresFence(kind StreamKind) bool
StreamKindRequiresFence identifies streams that cause an external action. Informational list/event streams remain compatible without a fence.
func ValidPort ¶ added in v0.1.4
ValidPort reports whether p is a usable TCP port number. The single spelling of the bound: both binaries import this package, so the check cannot drift between the agent's policy/TUI guards and the relay's.
func ValidTerminalSize ¶ added in v0.1.4
ValidTerminalSize reports whether a terminal's dimensions are within bounds.
func ValidateActionAck ¶ added in v0.1.6
func ValidateActionAck(h StreamHeader, ack ActionAck) error
ValidateActionAck accepts only terminal shutdown results for the exact header that opened the stream.
func ValidateStreamFence ¶ added in v0.1.6
func ValidateStreamFence(h StreamHeader, now time.Time) error
ValidateStreamFence is the point-in-time compatibility helper. Agent stream handlers use AcceptStreamFence instead so they never recreate a wall deadline.
func ValidateStreamFenceDeadline ¶ added in v0.1.6
ValidateStreamFenceDeadline performs a later check solely against the monotonic deadline captured by AcceptStreamFence.
func WriteActionAck ¶ added in v0.1.6
WriteActionAck writes one newline-delimited terminal shutdown result.
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 in a single Write.
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.
One Write, not two, because of what sits underneath. yamux emits a data frame per Stream.Write (up to its send window), and its send loop writes that frame's 12-byte header and its body as two separate writes on the tunnel conn, which websocket.NetConn turns into one WebSocket message each. So a record split across two writes cost four WebSocket messages and two yamux frames — one of the frames carrying nothing but a 4-byte length — and every keystroke paid it. It now costs two messages and one frame. The bytes on the wire are otherwise unchanged.
The whole record is copied into one buffer to do that. Inside this repo that only ever carries the two 32-byte hellos, but relay/ is shared: the server's browser-to-agent pump calls this per inbound record, so it trades a copy there for a frame and a message on every one.
Types ¶
type ActionAck ¶ added in v0.1.6
type ActionAck struct {
ActionFence string `json:"action_fence"`
NotAfterMilli int64 `json:"not_after_milli"`
Status ActionAckStatus `json:"status"`
}
ActionAck binds the terminal result to the exact durable capability and deadline from the shutdown header. Echoing both prevents a delayed response from completing a later stop request on a reused tunnel.
func NewActionAck ¶ added in v0.1.6
func NewActionAck(h StreamHeader, status ActionAckStatus) ActionAck
NewActionAck constructs an acknowledgment for h without letting callers accidentally omit either replay-binding field.
type ActionAckStatus ¶ added in v0.1.6
type ActionAckStatus string
ActionAckStatus is the terminal result of a shutdown action. Completing a success ACK write before NotAfter is the irrevocable shutdown commit point; root cancellation is its infallible fulfillment and may run after NotAfter if the scheduler pauses after that commit. Refused and expired mean the agent performed no shutdown action.
const ( ActionAckSuccess ActionAckStatus = "success" ActionAckRefused ActionAckStatus = "refused" ActionAckExpired ActionAckStatus = "expired" )
type DevicePollRequest ¶ added in v0.1.1
type DevicePollRequest struct {
DeviceCode string `json:"device_code"`
}
DevicePollRequest is the body of POST /device/poll.
type DevicePollResponse ¶ added in v0.1.1
type DevicePollResponse struct {
Status string `json:"status"`
ProvisionResponse
}
DevicePollResponse is the response of POST /device/poll. The endpoint always answers 200 — the flow state is carried in Status, not the HTTP status. When Status is DeviceStatusApproved the embedded ProvisionResponse fields carry exactly what a successful provision did: the pairing token, system id and display name the agent saves to its config.
type DeviceStartRequest ¶ added in v0.1.1
type DeviceStartRequest struct {
ClientID string `json:"client_id"`
Hostname string `json:"hostname"`
}
DeviceStartRequest is the body of POST /device/start, the unauthenticated opening of a device-authorization round: the calling system's stable client id and display hostname. No credentials travel here — a human approves the pairing out of band in the web app.
type DeviceStartResponse ¶ added in v0.1.1
type DeviceStartResponse struct {
UserCode string `json:"user_code"`
DeviceCode string `json:"device_code"`
VerificationURL string `json:"verification_url"`
ExpiresIn int `json:"expires_in"` // seconds until the codes expire
Interval int `json:"interval"` // seconds the agent waits between polls
}
DeviceStartResponse is returned by POST /device/start: the short code the user types into the web app, the opaque device code the agent polls with, the page to visit, and the flow's timing (durations in seconds).
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 ProvisionResponse ¶
type ProvisionResponse struct {
SystemID string `json:"systemId"`
Token string `json:"token"`
Name string `json:"name"`
}
ProvisionResponse is the approval payload of the device flow: 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.
The record is sealed straight into a buffer that already carries room for the length prefix, so one allocation and one Write carry the whole thing — see WriteRecord for what the second Write cost.
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, salt []byte, sessionID string) (*SessionKeys, error)
DeriveSessionKeys performs the X25519 agreement and expands it into the two directional keys.
Three things are bound into the schedule beyond the shared secret itself:
- Both public keys (scheduleInfo). A relay distributes the agent's public key to the client; if it substitutes one of its own, the client derives against that substituted key while the real agent derives against its own, so the two never agree and the stream fails closed. The seal alone already stops the relay reading traffic; binding the key is what stops it standing in as the endpoint against a client that pins the fingerprint.
- The per-connection server salt, as the HKDF salt. A fresh salt each connection guarantees a distinct key/nonce space even if a client reuses its ephemeral key across reattaches — the counter-nonce scheme is only safe while no key is ever reused.
- sessionID, so two tabs between the same two parties derive different keys and a record from one 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
ActionFence string `json:"action_fence,omitempty"` // opaque durable side-effect capability
NotAfterMilli int64 `json:"not_after_milli,omitempty"` // agent refuses the action at/after this instant
}
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" // 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.