wssession

package
v0.51.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 22, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

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

View Source
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.

View Source
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.

View Source
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

View Source
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

func ExtractTicket(r *http.Request) (ticket, subproto string)

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

func NormalizeAllowedOrigins(origins []string) []string

NormalizeAllowedOrigins trims the configured origin allowlist and drops a literal "*" unless EnvAllowWildcardOrigin opts the wildcard in (development only). Fail-closed by design.

func OriginAllowed

func OriginAllowed(origin string, allowed []string) bool

OriginAllowed reports whether the request Origin may upgrade a WebSocket. An empty Origin is denied unless EnvAllowEmptyOrigin opts native/CLI clients in.

func TicketSubprotocol

func TicketSubprotocol(ticket string) string

TicketSubprotocol renders the Sec-WebSocket-Protocol value for a ticket. Empty tickets yield an empty string.

func UpgradeOriginAllowed

func UpgradeOriginAllowed(r *http.Request, allowed []string) bool

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

type Identity struct {
	UserID string
	OrgID  string
	Plan   string
	Role   string
}

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.

const (
	StatePendingWS State = "pending_ws"
	StateActive    State = "active"
	StateClosed    State = "closed"
)

type VoicePrefs

type VoicePrefs struct {
	STTPrimary   string
	STTSecondary string
	VAProvider   string
	VAPersona    string
}

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL