Documentation
¶
Overview ¶
Package wssession holds the transport-neutral session plumbing shared by the Server-Target's ticket-authenticated WebSocket surfaces (Voice Agent, streaming Dictation):
- A Session Manager that tracks active sessions, enforces per-identity and global concurrency limits, and mints HMAC-signed one-time tickets so browser clients can upgrade a WebSocket without sending Authorization headers.
- Origin/allowlist checks and the "ticket.<value>" Sec-WebSocket-Protocol carrier (see origin.go).
- An idle watchdog for server-side session teardown (see idle_watchdog.go) and public ws(s):// URL building (see url.go).
The package is deliberately free of build tags and of linux-only imports so its ticket and codec logic stays natively testable on every development host; the mode packages that consume it stay `//go:build linux`.
Index ¶
- Constants
- Variables
- func ExtractTicket(r *http.Request) (ticket, subproto string)
- func NormalizeAllowedOrigins(origins []string) []string
- func OriginAllowed(origin string, allowed []string) bool
- func TicketSubprotocol(ticket string) string
- func UpgradeOriginAllowed(r *http.Request, allowed []string) bool
- func WebSocketURL(requestHost, apiPrefixHeader, publicURL string, requestIsHTTPS bool, ...) string
- type Identity
- type IdleWatchdog
- type ManagedSession
- type Options
- type SessionManager
- func (m *SessionManager) Attach(id string) error
- func (m *SessionManager) Create(owner Identity) (*ManagedSession, string, error)
- func (m *SessionManager) Get(id string) (*ManagedSession, error)
- func (m *SessionManager) List(userID string) []*ManagedSession
- func (m *SessionManager) Remove(id string)
- func (m *SessionManager) Stats(userID string) SessionStats
- func (m *SessionManager) TicketExpiresAt() time.Time
- func (m *SessionManager) VerifyTicket(sessionID, ticket string) error
- type SessionStats
- type State
- type VoicePrefs
Constants ¶
const EnvAllowEmptyOrigin = "SPEECHKIT_ALLOW_EMPTY_ORIGIN"
EnvAllowEmptyOrigin opts requests in that do not send an Origin header (CLIs, native clients, server-to-server tests) past the WebSocket upgrade gate. Default behaviour rejects empty Origin so a browser without CSRF protection cannot establish a session by stripping the header.
const EnvAllowWildcardOrigin = "SPEECHKIT_ALLOW_WILDCARD_ORIGIN"
EnvAllowWildcardOrigin permits a literal "*" entry in the allowed-origins list to match every Origin. This disables CSRF-style protection for the WebSocket upgrade and is intended for local development only. By default a "*" entry is dropped (fail-closed) and a loud warning is emitted at handler construction, so a stray wildcard in config cannot silently open the cross-origin surface in production.
const TicketSubprotocolPrefix = "ticket."
TicketSubprotocolPrefix carries the session ticket as a WebSocket subprotocol, e.g. "ticket.AbCd...". This keeps the ticket out of the URL and therefore out of any fronting reverse proxy's access log.
Variables ¶
var ( ErrSessionNotFound = errors.New("ws session: session not found") ErrSessionAlreadyActive = errors.New("ws session: session already has an active WS connection") ErrSessionExpired = errors.New("ws session: session ticket expired") ErrInvalidTicket = errors.New("ws session: ticket signature or payload invalid") ErrGlobalLimitExceeded = errors.New("ws session: global session limit exceeded") ErrIdentityLimitExceeded = errors.New("ws session: per-identity session limit exceeded") )
Common errors reported by the session manager.
Functions ¶
func ExtractTicket ¶
ExtractTicket reads the ticket from the Sec-WebSocket-Protocol subprotocol form. The subprotocol must look like "ticket.<value>"; the returned subproto string MUST be echoed back to the client via AcceptOptions.Subprotocols so the WS handshake completes per RFC 6455.
func NormalizeAllowedOrigins ¶
NormalizeAllowedOrigins trims the configured origin allowlist and drops a literal "*" unless EnvAllowWildcardOrigin opts the wildcard in (development only). Fail-closed by design.
func OriginAllowed ¶
OriginAllowed reports whether the request Origin may upgrade a WebSocket. An empty Origin is denied unless EnvAllowEmptyOrigin opts native/CLI clients in.
func TicketSubprotocol ¶
TicketSubprotocol renders the Sec-WebSocket-Protocol value for a ticket. Empty tickets yield an empty string.
func UpgradeOriginAllowed ¶
UpgradeOriginAllowed reports whether a WebSocket upgrade request may proceed to ticket verification. Browser requests always carry an Origin header and stay subject to the allowlist (CSRF protection). A request with no Origin that presents a ticket subprotocol is a native client — browsers cannot omit Origin — and its single-session HMAC ticket (minted via the authenticated session POST) is the credential: the upgrade proceeds and stands or falls with VerifyTicket immediately after. Ticketless empty-Origin requests still require the EnvAllowEmptyOrigin opt-in.
func WebSocketURL ¶
func WebSocketURL(requestHost, apiPrefixHeader, publicURL string, requestIsHTTPS bool, relPath string) string
WebSocketURL builds the externally visible ws(s):// URL for a session endpoint mounted under the versioned API base. relPath is the path below that base, e.g. "/voiceagent/sessions/<id>/ws" or "/dictation/stream/sessions/<id>/ws".
The configured publicURL (when parseable) wins over request-derived scheme/host so Docker/proxy deployments return a browser-reachable URL rather than a container hostname. requestIsHTTPS is the caller's trusted-proxy-aware TLS determination for the incoming request; apiPrefixHeader is the raw value of the httpx.APIPrefixHeader request header (passed in, not read here, so this package stays free of the linux-only httpx import and natively testable).
Types ¶
type Identity ¶
Identity is the caller's resolved identity from the auth middleware. Stored on each session so closes can verify ownership.
type IdleWatchdog ¶
type IdleWatchdog struct {
// contains filtered or unexported fields
}
IdleWatchdog signals on its Fired channel when the configured timeout elapses without a Reset call. Designed to be embedded in a WebSocket adapter so server-side idle sessions are torn down without waiting for the client to disconnect.
Lifecycle:
w := NewIdleWatchdog(15 * time.Minute)
defer w.Stop()
for {
select {
case <-frameArrived:
w.Reset()
case <-w.Fired():
// close session with reason="idle"
}
}
Safe for concurrent Reset/Stop. A timeout of 0 returns a watchdog whose Fired channel never fires — useful for tests and for callers who want to disable the timeout via config.
func NewIdleWatchdog ¶
func NewIdleWatchdog(timeout time.Duration) *IdleWatchdog
NewIdleWatchdog constructs a watchdog with the given timeout.
func (*IdleWatchdog) Fired ¶
func (w *IdleWatchdog) Fired() <-chan struct{}
Fired returns a channel that receives once when the timeout elapses without a Reset.
func (*IdleWatchdog) Reset ¶
func (w *IdleWatchdog) Reset()
Reset restarts the timeout. No-op when the watchdog is stopped or the timeout has already fired (one-shot).
func (*IdleWatchdog) Stop ¶
func (w *IdleWatchdog) Stop()
Stop halts the timer permanently. Idempotent. After Stop, Fired() will not produce any new value.
type ManagedSession ¶
type ManagedSession struct {
ID string
Owner Identity
CreatedAt time.Time
State State
HasWSClient bool // true once the client has upgraded
// BridgeCredential is the opaque per-session credential the fronting
// proxy forwarded at session creation (see middleware
// EdgeOboSubjectTokenFromContext). The Voice Agent surface sets it at
// upgrade time to authorize that session's calls to the external tool
// bridge; the streaming-Dictation surface leaves it empty. MEMORY-ONLY
// SECRET: it is never minted into tickets, never serialized to the wire
// (listedSession and createSessionResponse deliberately exclude it),
// never persisted, and MUST never appear in slog attributes.
BridgeCredential string
// VoicePrefs carries the edge-resolved user voice preferences captured at
// session-mint time (see middleware.VoicePrefsFromContext). Mode adapters
// use them as defaults when the client's start frame omits an explicit
// provider or persona; explicit start-frame values always win. Non-secret
// names only — safe to log, never keys.
VoicePrefs VoicePrefs
}
ManagedSession is the manager's record for one active or pending session. The WebSocket handler and adapter pull additional fields (conn, pumps) onto this struct at handshake time.
type Options ¶
type Options struct {
// TicketSecret signs the one-time WS upgrade tickets. Must be at least
// 16 bytes; when empty, the manager generates a random secret at
// construction time (acceptable only for single-process deployments).
TicketSecret []byte
// TicketTTL limits how long a minted ticket remains valid. Defaults to
// 90 seconds.
TicketTTL time.Duration
// MaxGlobalSessions caps concurrent sessions across all callers.
// Defaults to 100.
MaxGlobalSessions int
// MaxPerIdentitySessions caps concurrent sessions per caller.
// Defaults to 3.
MaxPerIdentitySessions int
// Clock is a time source; nil means time.Now. Tests can override.
Clock func() time.Time
}
Options configures a SessionManager.
type SessionManager ¶
type SessionManager struct {
// contains filtered or unexported fields
}
SessionManager tracks active WebSocket sessions for one server surface.
func NewSessionManager ¶
func NewSessionManager(opts Options) (*SessionManager, error)
NewSessionManager constructs a manager with opts. Unset fields receive sensible defaults.
func (*SessionManager) Attach ¶
func (m *SessionManager) Attach(id string) error
Attach moves a session from pending to active. Returns an error when the session is already attached to another WS client or has been closed.
func (*SessionManager) Create ¶
func (m *SessionManager) Create(owner Identity) (*ManagedSession, string, error)
Create registers a new pending session for the given caller and returns the session record plus a one-time WS upgrade ticket. Fails with a limit error when concurrency caps are exceeded.
func (*SessionManager) Get ¶
func (m *SessionManager) Get(id string) (*ManagedSession, error)
Get returns the session with the given ID, or ErrSessionNotFound.
func (*SessionManager) List ¶
func (m *SessionManager) List(userID string) []*ManagedSession
List returns a snapshot of sessions owned by the given user (or all when userID is empty — reserved for admin callers).
func (*SessionManager) Remove ¶
func (m *SessionManager) Remove(id string)
Remove closes and removes the session. Safe to call multiple times.
func (*SessionManager) Stats ¶
func (m *SessionManager) Stats(userID string) SessionStats
Stats returns a point-in-time session and capacity snapshot.
func (*SessionManager) TicketExpiresAt ¶
func (m *SessionManager) TicketExpiresAt() time.Time
TicketExpiresAt reports when a ticket minted right now would expire. Session handlers surface this in their create responses so clients know how long the WS upgrade window stays open.
func (*SessionManager) VerifyTicket ¶
func (m *SessionManager) VerifyTicket(sessionID, ticket string) error
VerifyTicket validates a ticket string against its expected session ID. Returns nil when the ticket is cryptographically valid, un-expired, and bound to this sessionID. Mutates nothing; callers must follow up with Attach to mark the session active.
type SessionStats ¶
type SessionStats struct {
TotalSessions int `json:"total_sessions"`
ActiveSessions int `json:"active_sessions"`
PendingSessions int `json:"pending_sessions"`
IdentitySessions int `json:"identity_sessions,omitempty"`
MaxGlobalSessions int `json:"max_global_sessions"`
MaxPerIdentitySessions int `json:"max_per_identity_sessions"`
}
SessionStats reports current session occupancy and configured capacity.
type State ¶
type State string
State captures the session's manager-level lifecycle. The Framework kernel has its own finer-grained state; this one only distinguishes pending ticket vs. active connection.
type VoicePrefs ¶
VoicePrefs mirrors middleware.VoicePrefs (field-for-field, so handlers can convert directly) without importing the linux-only middleware package — this package stays build-tag-free and natively testable, the same reason Identity above duplicates the middleware's Identity fields. The values are non-secret provider/persona names the fronting edge resolved for the user; they are captured at session-mint time because the WebSocket upgrade authenticates via ticket and never sees the edge headers.