Documentation
¶
Overview ¶
Package librelay defines the wire contract between a contenox runtime and a relay: the Frame envelope, its NDJSON codec (Reader, Writer), and the relay-level control messages. A runtime dials out and holds the connection; nothing here listens, and nothing here knows a hostname — the endpoint and the relay's public key are configuration.
The package exists at the module root rather than under internal/ because a relay implementation is a separate Go module and cannot import internal/. Both ends must compile against one definition of the envelope; two definitions that drift is the failure this placement prevents.
The envelope ¶
A frame carries routing (Frame.Instance, Frame.Session), a discriminator (Frame.Type), correlation (Frame.ID / Frame.ReplyTo) and an opaque Frame.Payload. The payload is json.RawMessage precisely so a relay can route a frame without parsing what is inside it; a relay that unmarshals ACP has taken on a dependency the design says it does not have.
There is no transport encryption layer here. That is decided: the relay reads frames and TLS provides confidentiality, so the envelope is plain readable JSON with no sealed blob and no split between routing header and body.
One type space ¶
Control and tunnelled traffic share Frame.Type rather than living in two fields. A receiver's read loop then makes exactly one decision per frame: ControlPrefix means "for me", anything else means "route it". Two fields can disagree with each other and every disagreement is a case somebody has to invent behavior for.
Compatibility ¶
Unknown is never fatal, in both directions:
- Unknown fields are ignored (the decoder does not use DisallowUnknownFields), so the envelope evolves by addition only. A field may never change meaning.
- An unknown non-control type is opaque and gets routed on (instance, session) as any other tunnelled frame would.
- An unknown control type is answered by Unsupported when it is a request and dropped when it is not. A request always gets exactly one reply, so a newer peer talking to an older one fails fast instead of blocking on a response that will never come; a response or notification never induces a reply, so two peers cannot ping-pong errors at each other.
⚠ "Ignored" holds for an endpoint, which decodes a frame and acts on it. It does NOT hold for a relay, which decodes a frame and RE-ENCODES it to forward: there an unknown field is not ignored, it is destroyed, and silently, because nothing on either side can see that it went missing. A relay must therefore be built against a version of this package at least as new as the endpoints it carries. Adding a field here is consequently a two-module change — the field, then the relay's dependency on it — and shipping only the first half is indistinguishable from the feature not working.
The protocol version is negotiated once in Hello / Welcome and is deliberately not a per-frame field: it would cost bytes on every frame to carry a number that cannot change mid-connection, and it could not help anyway — a framing change severe enough to need it is a change that makes the frame unparseable before the version is reachable.
Authentication ¶
The handshake is mutual but asymmetric, because the two directions have different problems. The instance proves itself to the relay with a bearer credential on the transport's upgrade request — never in a frame, so nothing here handles it. The relay proves itself to the instance inside the handshake: Hello carries a fresh Hello.Nonce and Welcome answers with Welcome.Signature, checked by VerifyWelcome against the public key the instance stored when it paired. Both ends compute the signed bytes with SigningInput, which is the reason it lives in this shared module.
Resumption ¶
A dropped connection costs latency, never content. Frame.Seq is the producer's per-session cursor; on reconnect the receiver sends Resume with the last value it saw and the producer continues after it. That is SSE's model — one cursor per connection rather than an acknowledgement per message — and it is why nothing here carries delivery guarantees.
Resumption is not relay control traffic. The producer replays because it is the side still holding the content, so TypeResume routes end to end and a relay treats it as any other cargo. It also travels one way only: replaying a command is not resumption, since a re-delivered instruction is a second instruction.
Framing ¶
NDJSON, the same framing ACP uses. The payload is already a JSON value, so a connector splices an ACP line into a frame with a byte copy and no re-encode, and neither end links a second parser. Writer compacts the payload, which is what makes newline-delimiting safe: a raw newline between JSON tokens in a caller-supplied payload would otherwise split one frame into two.
Index ¶
- Constants
- Variables
- func FormatPublicKey(pub libcipher.SigningPublicKey) string
- func IsControl(msgType string) bool
- func NewNonce() ([]byte, error)
- func NewTraceID() string
- func ParsePublicKey(s string) (libcipher.SigningPublicKey, error)
- func SignWelcome(priv libcipher.SigningPrivateKey, nonce []byte, negotiatedVersion int, ...) ([]byte, error)
- func SigningInput(nonce []byte, negotiatedVersion int, instance string) ([]byte, error)
- func ValidTraceID(s string) bool
- func VerifyWelcome(pub libcipher.SigningPublicKey, nonce []byte, negotiatedVersion int, ...) error
- type ChainTrigger
- type ChainTriggerResult
- type Error
- type Frame
- type Hello
- type Reader
- type Resume
- type Resumed
- type Welcome
- type Writer
Constants ¶
const ( NonceSize = 32 MaxNonceBytes = 64 )
Nonce sizes. NonceSize is what a connector generates; MaxNonceBytes is what a relay will sign over, bounding the work an unauthenticated peer can ask for with a single hello.
const ( MaxFrameBytes = 16 << 20 MaxTypeBytes = 128 MaxIDBytes = 256 )
Envelope limits bound what a decoder will allocate for a single frame before it gives up.
const ( // TypeHello is the connector's opening frame, payload [Hello]; a request answered with TypeWelcome or TypeError. TypeHello = ControlPrefix + "hello" // TypeWelcome is the relay's answer to TypeHello, payload [Welcome]. TypeWelcome = ControlPrefix + "welcome" // TypeHeartbeat is a liveness probe with no payload, sent by either end; a request answered with TypeAck. TypeHeartbeat = ControlPrefix + "heartbeat" // TypeAck answers TypeHeartbeat, carrying no payload. TypeAck = ControlPrefix + "ack" // TypeError reports a failure, payload [Error]; as a response it never induces a response. TypeError = ControlPrefix + "error" )
Control message types; part of the protocol floor and may never be removed.
const ( // TypeChainTrigger asks the machine behind [Frame.Instance] to run a named task chain, payload [ChainTrigger]; relay→machine only. TypeChainTrigger = "chain_trigger" // TypeChainTriggerResult reports a chain trigger's outcome, payload [ChainTriggerResult]; machine→relay only, exactly one per [ChainTrigger.RequestID]. TypeChainTriggerResult = "chain_trigger_result" )
Chain-trigger types are cargo, not control traffic: a relay addresses a trigger to one instance and the machine answers with the result type.
const ( // ChainSessionNew runs the chain in a fresh session; the mode every machine implements. ChainSessionNew = "new" // ChainSessionReused asks the machine to reuse a session across triggers; unsupported machines refuse rather than silently downgrading to new. ChainSessionReused = "reused" )
ChainTrigger.SessionMode values.
const ( // ChainTriggerStatusOK: the chain ran to completion. ChainTriggerStatusOK = "ok" // ChainTriggerStatusError: the chain started and failed. ChainTriggerStatusError = "error" // ChainTriggerStatusRefused: the machine declined before any chain ran; a clean answer, not a run failure. ChainTriggerStatusRefused = "refused" )
ChainTriggerResult.Status values.
const ( // TypeResume asks a session's producer to continue after a cursor; always a request, answered exactly once. TypeResume = "session.resume" // TypeResumed answers [TypeResume] and precedes the replayed frames. TypeResumed = "session.resumed" )
Resumption types are not control traffic: a relay routes them to the producer holding the content to replay. See Resume.
const ( CodeUnsupportedType = "unsupported_type" CodeMalformedFrame = "malformed_frame" CodeUnknownInstance = "unknown_instance" CodeVersion = "unsupported_version" // CodeCursorEvicted answers a [Resume] whose cursor the producer no longer retains at all; a partial replay sets [Resumed].Evicted instead. CodeCursorEvicted = "cursor_evicted" )
Error codes carried by Error; strings rather than integers so an unrecognized code degrades to something legible in a log.
const ControlPrefix = "relay."
ControlPrefix marks a type as relay-level control traffic; a relay handles frames with this prefix and forwards everything else.
const MaxTraceBytes = 128
MaxTraceBytes bounds Frame.Trace. It is far below MaxIDBytes because a trace is not addressable: nothing routes on it, nothing looks it up, and the only thing it is ever spent on is a log field. A ceiling this low is what stops a peer from renting space in every activity record this runtime writes.
const ProtocolVersion = 1
ProtocolVersion is the envelope version this build speaks; exchanged once in Hello/Welcome, never on a frame.
const SigningDomain = "contenox-relay/welcome/v1"
SigningDomain is the domain-separation tag every Welcome signature starts with. It stops a signature being replayed into any other context that ever signs with the same key: a verifier for a different purpose would have to accept this tag to be fooled, and it never will.
const TraceAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
TraceAlphabet is the complete set of bytes Frame.Trace may contain: unreserved URL characters and nothing else.
It is deliberately narrower than the rule the routing identifiers obey. Those only exclude what corrupts a log line (ErrControlChar) because they carry a peer's chosen names; a trace carries no name and needs no expressiveness, so it is restricted to the alphabet NewTraceID emits. That leaves no quote, no separator, no whitespace and no non-ASCII byte — nothing that could be read as structure by whatever the value is eventually pasted into.
const TypeACPDetach = "acp.detach"
TypeACPDetach reports that the client behind one attachment is gone; it carries no payload, and is a hint rather than a prerequisite for anything.
const TypeACPMessage = "acp.message"
TypeACPMessage tunnels one ACP JSON-RPC message, byte for byte; a relay routes it without parsing or linking libacp.
Variables ¶
var ( ErrNonceSize = errors.New("librelay: nonce is missing or larger than MaxNonceBytes") ErrNoSignature = errors.New("librelay: welcome carries no signature") ErrBadSignature = errors.New("librelay: welcome signature does not verify") ErrBadPublicKey = errors.New("librelay: not an ed25519 public key") ErrNulByte = errors.New("librelay: signing input contains a NUL byte") )
Relay-authentication failures. They are all "this peer did not prove it is the relay this instance paired with", which a connector treats as fatal rather than transient: a relay that cannot prove itself now will not start being able to on the next dial.
var ( ErrFrameTooLarge = errors.New("librelay: frame exceeds MaxFrameBytes") ErrReaderClosed = errors.New("librelay: reader is unusable after a framing error") )
Codec-level failures. ErrFrameTooLarge is the only one that ends a connection: every other error here is per-frame and the Reader resumes at the next line, because a peer that emits one bad frame has not proved the stream is unusable.
var ( ErrEmptyType = errors.New("librelay: frame type is empty") ErrTypeTooLong = errors.New("librelay: frame type exceeds MaxTypeBytes") ErrIDTooLong = errors.New("librelay: identifier exceeds MaxIDBytes") ErrControlChar = errors.New("librelay: identifier contains a control character") ErrBothIDs = errors.New("librelay: frame sets both id and re") ErrSessionAlone = errors.New("librelay: frame sets session without instance") ErrNotUTF8 = errors.New("librelay: identifier is not valid UTF-8") )
Validation failures: all "this frame is not addressable", treated as a bad message rather than a bad connection.
var ( ErrTraceTooLong = errors.New("librelay: trace exceeds MaxTraceBytes") ErrTraceCharset = errors.New("librelay: trace contains a byte outside TraceAlphabet") )
Trace validation failures. Unlike the errors in frame.go these do not mean "unroutable"; they mean "unsafe to log", which Frame.Validate rejects for the same reason and on the same terms.
Functions ¶
func FormatPublicKey ¶
func FormatPublicKey(pub libcipher.SigningPublicKey) string
FormatPublicKey renders a relay's key for storage and for an enrolment payload. The encoding is libcipher.SigningKeyEncoding; this is a spelling of libcipher.FormatPublicKey kept so relay callers do not have to learn a second package for one call, not a second implementation.
func IsControl ¶
IsControl reports whether a type is relay-level control traffic; a relay handles these and forwards everything else.
func NewNonce ¶
NewNonce returns a fresh challenge for Hello.Nonce. It must be generated per connection and never reused: reuse is what would let a signature captured from one session be replayed into another.
func NewTraceID ¶
func NewTraceID() string
NewTraceID mints a fresh trace for one action. Call it where an action begins — one browser request, one prompt — and never once per connection: a value that lives as long as a socket files a day's work under one key and correlates nothing.
Uses math/rand/v2, not crypto/rand, deliberately and for the same reason libtracker.WithNewRequestID does: a trace is a correlation key only, never authenticated and never authorized on, so the only requirement is collision avoidance. Do not reuse one as a token, a nonce or an idempotency key.
The "tr-" prefix makes the value self-describing in a log where request ids and span ids sit beside it.
func ParsePublicKey ¶
func ParsePublicKey(s string) (libcipher.SigningPublicKey, error)
ParsePublicKey reads a key produced by FormatPublicKey, delegating to libcipher.ParsePublicKey for which spellings are accepted. The error is wrapped so it matches both ErrBadPublicKey — which relay callers already test for — and the libcipher sentinel underneath it.
func SignWelcome ¶
func SignWelcome(priv libcipher.SigningPrivateKey, nonce []byte, negotiatedVersion int, instance string) ([]byte, error)
SignWelcome produces the Welcome.Signature a relay answers hello with. The version is the one the relay selected, not the one the connector offered: the connector verifies against what it was told, so signing the offer would never verify.
func SigningInput ¶
SigningInput is the exact byte string a Welcome signature covers. It is the single definition of that layout — both the signing and the verifying path call it, and a second copy of the concatenation anywhere is the bug this function exists to make impossible:
SigningDomain ‖ 0x00 ‖ base64url(nonce) ‖ 0x00 ‖ decimal(version) ‖ 0x00 ‖ instance
Every part is UTF-8 bytes, the separator is one NUL, the version is ASCII digits with no padding, and the nonce is unpadded base64url — the same text it travels as in JSON, and text that cannot contain a NUL. Plain concatenation would not do: ("a", 11, "c") and ("a", 1, "1c") both flatten to "a11c", so one signature would cover two different triples. The separators are what make the parse unambiguous, which is why an input that could contain one is refused rather than signed over.
func ValidTraceID ¶
ValidTraceID reports whether s may be carried in Frame.Trace. The empty string is valid: absent is the normal state of an untraced frame, not a defect.
It is exported so a receiver can check a value it did not get from Reader.ReadFrame — a frame built in-process, or one that crossed a hop that predates this field — before letting it reach a log. Validating twice costs a scan of at most MaxTraceBytes and removes the need to reason about which frames arrived by which path.
func VerifyWelcome ¶
func VerifyWelcome(pub libcipher.SigningPublicKey, nonce []byte, negotiatedVersion int, instance string, sig []byte) error
VerifyWelcome checks a relay's answer against the public key pinned at pairing time. A nil error is the only success; every other outcome is one of the sentinels above and none of them is retryable.
Types ¶
type ChainTrigger ¶ added in v0.40.0
type ChainTrigger struct {
// RequestID correlates the [ChainTriggerResult] with this trigger; minted by the relay, echoed by the machine verbatim.
RequestID string `json:"request_id"`
// Chain names the chain file on the machine's own configuration path; what it resolves to is the machine's decision.
Chain string `json:"chain"`
// SessionMode is [ChainSessionNew] or [ChainSessionReused].
SessionMode string `json:"session_mode"`
// Input is the event envelope the chain receives, raw; the relay never parses it.
Input json.RawMessage `json:"input"`
// Policy optionally names the HITL policy envelope for the run; empty applies the machine's own default.
Policy string `json:"policy,omitempty"`
}
ChainTrigger is the TypeChainTrigger payload: run this chain with this input.
type ChainTriggerResult ¶ added in v0.40.0
type ChainTriggerResult struct {
// RequestID is [ChainTrigger.RequestID], echoed verbatim.
RequestID string `json:"request_id"`
// Status is one of the ChainTriggerStatus constants.
Status string `json:"status"`
// Error says why, for [ChainTriggerStatusError] and [ChainTriggerStatusRefused]; empty on ok.
Error string `json:"error,omitempty"`
}
ChainTriggerResult is the TypeChainTriggerResult payload.
type Error ¶
type Error struct {
// Code is machine-readable and stable; see the Code* constants.
Code string `json:"code"`
// Message is for humans and may change freely between versions.
Message string `json:"message,omitempty"`
}
Error is the TypeError payload.
type Frame ¶
type Frame struct {
// Type discriminates the frame; control types carry ControlPrefix, everything else is routed opaquely.
Type string `json:"type"`
// Instance is the runtime instance this frame concerns; empty only on frames that precede identification.
Instance string `json:"instance,omitempty"`
// Session names the stream within Instance that this frame concerns; a relay routes on the value without interpreting it. Empty on control traffic and anything instance-scoped.
Session string `json:"session,omitempty"`
// ID marks this frame as a request and correlates the reply; its presence, not the type, obliges exactly one answer.
ID string `json:"id,omitempty"`
// ReplyTo carries the ID of the request being answered and marks this frame as a response.
ReplyTo string `json:"re,omitempty"`
// Seq is the producer's per-(Instance, Session) cursor for this frame, monotonically increasing and gap-free within a session; zero means unsequenced.
Seq uint64 `json:"seq,omitempty"`
// Trace is the correlation key for the one human action this frame belongs to; peer-supplied text that must never be authorized on, only logged and joined. [MaxTraceBytes]/[TraceAlphabet] bound it; empty means untraced.
Trace string `json:"trace,omitempty"`
// Payload is the message body, left as raw JSON so intermediaries do not parse it; empty is legal.
Payload json.RawMessage `json:"payload,omitempty"`
}
Frame is the transport envelope; wire field names are short because every ACP message pays for them. Instance and Session form the routing key and are direction-independent.
func NewError ¶
NewError builds a response carrying code and message, correlated to req when req is a request.
func Unsupported ¶
Unsupported returns the reply owed to a frame this build cannot handle, and whether one is owed at all.
func (Frame) DecodePayload ¶
DecodePayload unmarshals f's payload into v; an absent payload decodes to nothing and is not an error.
func (Frame) IsRequest ¶
IsRequest reports whether f obliges the receiver to send exactly one response.
func (Frame) IsResponse ¶
IsResponse reports whether f answers an earlier request; a response is never answered.
type Hello ¶
type Hello struct {
// ProtocolVersion is the highest envelope version the connector speaks.
ProtocolVersion int `json:"protocol_version"`
// Instance identifies the runtime; also set on the frame since routing must not require reading a payload.
Instance string `json:"instance"`
// Agent names the implementation and version, for operator diagnosis only; nothing may branch on it.
Agent string `json:"agent,omitempty"`
// Nonce is a fresh random challenge the relay signs into [Welcome.Signature]; generated per connection, never a secret, useful once.
Nonce []byte `json:"nonce,omitempty"`
}
Hello is the TypeHello payload: what the connector claims to be, before the relay has agreed to any of it.
type Reader ¶
type Reader struct {
// contains filtered or unexported fields
}
Reader decodes NDJSON frames from a stream. It is not safe for concurrent use; one goroutine owns the read side of a connection.
func (*Reader) ReadFrame ¶
ReadFrame returns the next frame.
It distinguishes two kinds of failure, and callers must too. A malformed or invalid frame is reported with the line already consumed, so calling ReadFrame again is correct and reads the next frame; this is what lets one garbled message not take down a connection carrying other sessions. ErrFrameTooLarge is different: the offending line has not been consumed and cannot be, since consuming it is the unbounded read the limit exists to prevent. The Reader is dead after it and every later call returns ErrReaderClosed — resynchronizing on a newline an attacker chose would hand them control of where the next frame starts.
type Resume ¶
type Resume struct {
// AfterSeq is the last [Frame.Seq] the requester saw; zero asks for the whole retained stream.
AfterSeq uint64 `json:"after_seq"`
}
Resume is the TypeResume payload: continue a session's stream after a cursor; not relay control traffic, routed end to end like cargo.
type Resumed ¶
type Resumed struct {
// FromSeq is the first [Frame.Seq] that follows; greater than Resume.AfterSeq+1 only when Evicted is set.
FromSeq uint64 `json:"from_seq"`
// Evicted reports the requested cursor is older than what the producer retains; a receiver seeing this must refetch state rather than append.
Evicted bool `json:"evicted,omitempty"`
}
Resumed is the TypeResumed payload: what the producer is about to send.
type Welcome ¶
type Welcome struct {
// ProtocolVersion is the version the relay selected, min(peer, self); the connector must close if it's lower than it can speak.
ProtocolVersion int `json:"protocol_version"`
// Relay names the relay implementation and version, diagnostics only.
Relay string `json:"relay,omitempty"`
// Signature is Ed25519 over [SigningInput] of Hello.Nonce, ProtocolVersion and instance; omitempty since only a connector pinning a key requires it.
Signature []byte `json:"sig,omitempty"`
// RetryAfterSeconds hints how long to wait before redialling; a connector clamps it to its own bounds. Zero means the connector's own backoff applies.
RetryAfterSeconds int `json:"retry_after,omitempty"`
}
Welcome is the TypeWelcome payload: the relay's acceptance.
type Writer ¶
type Writer struct {
// contains filtered or unexported fields
}
Writer encodes frames as NDJSON. It is safe for concurrent use: a connection is written by more than one goroutine (a session stream and a heartbeat, at minimum) and an interleaved frame is a corrupt stream, not a slow one.
func (*Writer) WriteFrame ¶
WriteFrame validates and writes f followed by a newline.
It fails closed: an invalid frame is never partially written, because the encoding happens into a buffer and reaches the connection as a single Write. A frame that failed halfway across would desynchronize framing for every session sharing the connection, not just its own.