GoSpeak uses three transport layers: a TCP/TLS 1.3 control plane for signalling, a UDP voice plane for real-time audio, and a TCP/TLS screen plane for low-rate screen-share media.
- Port: 9600 (default)
- Transport: TCP with TLS 1.3 (self-signed certificates auto-generated on first run)
- Framing: Length-prefixed JSON — each message is preceded by a 4-byte big-endian uint32 length header
- Serialization: JSON with
omitempty— only the populated field inControlMessageis serialized
Every control message is a ControlMessage struct with exactly one field set. Writers reject empty envelopes, envelopes with multiple populated fields, and null message values. Readers additionally reject repeated top-level fields (including escaped equivalents), unknown top-level fields, and malformed or trailing JSON. Unknown fields are rejected deliberately: peers using a protocol version newer than the reader's must not send message types the reader does not know.
AuthRequestAuthResponseChannelListRequestChannelListResponseJoinChannelRequestChannelJoinResponseLeaveChannelRequestChannelJoinedEventChannelLeftEventUserStateUpdateServerStateEventCreateChannelRequestDeleteChannelRequestCreateTokenRequestCreateTokenResponseKickUserRequestBanUserRequestChatMessageChatEventScreenShareStartRequestScreenShareStopRequestScreenShareSubscribeRequestScreenShareShareRequestScreenShareUnsubscribeRequestScreenShareEventScreenShareFrameSetUserRoleRequestSetUserRoleResponseExportDataRequestExportDataResponseImportChannelsRequestImportChannelsResponseErrorResponsePing/Pong
┌──────────────────────────────────────────┐
│ 4 bytes: message length (big-endian) │
├──────────────────────────────────────────┤
│ N bytes: JSON-encoded ControlMessage │
└──────────────────────────────────────────┘
sequenceDiagram
participant C as Client
participant S as Server
C->>S: AuthRequest{token?, username}
alt New user (invite or open server)
S->>S: Validate invite token or allow open join
S->>S: Create user + personal token
S->>S: Check bans
S->>S: Generate session
S->>C: AuthResponse{sessionID, role, encryptionKey, voiceRegistrationKey, screenShareEnabled?, screenAddr?, screenAuthToken?, channels, autoToken}
Note over C: Store personal token for reconnect
else Existing user
S->>S: Require personal token
S->>S: Check bans
S->>S: Generate session
S->>C: AuthResponse{sessionID, role, encryptionKey, voiceRegistrationKey, screenShareEnabled?, screenAddr?, screenAuthToken?, channels}
else Invalid token / banned
S->>C: ErrorResponse{code, message}
S->>S: Close connection
end
sequenceDiagram
participant C as Client
participant S as Server
participant Others as Other Clients
Note over C,S: Join Channel
C->>S: JoinChannelRequest{channelID}
S->>S: Validate and atomically reserve capacity
S->>C: ChannelJoinResponse{channelID, success, message}
S->>Others: ChannelJoinedEvent{channelID, user}
S->>C: ServerStateEvent{channels} (full refresh)
Note over C,S: Leave Channel
C->>S: LeaveChannelRequest{}
S->>Others: ChannelLeftEvent{channelID, userID}
S->>C: ServerStateEvent{channels}
Note over C,S: Create Channel (Admin)
C->>S: CreateChannelRequest{name, desc, maxUsers, parentID, isTemp}
S->>S: RBAC check → PermCreateChannel
S->>C: ServerStateEvent{channels}
Note over C,S: Delete Channel (Admin)
C->>S: DeleteChannelRequest{channelID}
S->>S: RBAC check → PermDeleteChannel
S->>C: ServerStateEvent{channels}
sequenceDiagram
participant A as Client A
participant S as Server
participant B as Client B
A->>S: ChatMessage{channelID, text}
S->>S: Attach senderID, senderName, timestamp
S->>A: ChatEvent (echo back)
S->>B: ChatEvent (to all in channel)
The control plane carries screen-share lifecycle messages only:
ScreenShareStartRequestScreenShareStopRequestScreenShareSubscribeRequestScreenShareShareRequestScreenShareUnsubscribeRequestScreenShareEvent
ScreenShareEvent is broadcast to channel members for presence updates. A targeted copy with an encryption_key is sent to the active sharer, to users already in the channel when sharing starts, and to current channel members if the sharer later shares the active key with the channel again.
| Message | Direction | Description |
|---|---|---|
CreateTokenRequest |
Client → Server | Generate invite token with role, scope, max uses, expiry |
CreateTokenResponse |
Server → Client | Returns raw token string |
KickUserRequest |
Client → Server | Kick user by ID with reason |
BanUserRequest |
Client → Server | Ban user with optional duration |
SetUserRoleRequest |
Client → Server | Promote/demote user (admin only) |
SetUserRoleResponse |
Server → Client | Success/failure message |
ExportDataRequest |
Client → Server | Export channels or users as YAML |
ExportDataResponse |
Server → Client | YAML string data |
ImportChannelsRequest |
Client → Server | Import channels from YAML |
ImportChannelsResponse |
Server → Client | Success/failure message |
- Port: 9601 (default)
- Transport: Raw UDP
- Encryption: AES-128-GCM (shared key distributed in
AuthResponse) - Codec: Opus at 48 kHz mono, 20ms frames (960 samples)
A voice endpoint is not learned from an ordinary voice packet. During control authentication, the server creates a random 32-byte voice_registration_key for that session and returns it inside the TLS-protected AuthResponse. The client immediately sends this registration datagram and refreshes it every five seconds:
[Magic "GSR1":4B][SessionID:4B][Counter:8B][HMAC-SHA-256:32B]
The HMAC covers the first 16 bytes. The server accepts only a valid proof for the named active session with a counter greater than every previously accepted counter. Once a registration is accepted, the same datagram cannot be replayed. A source-address change requires a fresh proof and is accepted at most once per five seconds; this permits controlled NAT rebinding without STUN or TURN. Voice packets from unregistered or mismatched endpoints are dropped.
This field is required: clients and servers from before authenticated UDP registration are not voice-compatible with this protocol revision and fail closed rather than falling back to first-packet binding.
┌─────────────────────────────────────────────────────────┐
│ Header (20 bytes, sent as plaintext additional data) │
│ ┌───────────────┬─────────────┬──────────────┬────────┐ │
│ │SessionID (4B) │SeqNum (4B) │Timestamp (4B)│Chan(8B)│ │
│ └───────────────┴─────────────┴──────────────┴────────┘ │
├─────────────────────────────────────────────────────────┤
│ Payload: AES-128-GCM(opus_frame) │
│ ┌──────────────────────────────────────────────┐ │
│ │ Ciphertext (variable) + Auth Tag (16 bytes) │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
The 64-bit unsigned channel field carries positive SQLite channel IDs without truncation. The 20-byte header is authenticated as AES-GCM additional data. Clients and servers using the former 14-byte voice header are not wire-compatible.
graph LR
subgraph "Client A (Sender)"
MIC[Microphone<br/>PortAudio] --> PCM[PCM 48kHz<br/>16-bit mono]
PCM --> VAD{VAD<br/>Check}
VAD -->|Active| ENC[Opus<br/>Encoder]
VAD -->|Silent| DROP[Drop]
ENC --> ENCRYPT[AES-128-GCM<br/>Encrypt]
ENCRYPT --> UDP_OUT[UDP Send]
end
UDP_OUT --> SFU
subgraph Server
SFU[SFU<br/>Relay to<br/>channel members]
end
SFU --> UDP_IN
subgraph "Client B (Receiver)"
UDP_IN[UDP Recv] --> DECRYPT[AES-128-GCM<br/>Decrypt]
DECRYPT --> JITTER[Jitter<br/>Buffer]
JITTER --> DEC[Opus<br/>Decoder]
DEC --> SPK[Speaker<br/>PortAudio]
end
The server does not decode voice packets. It:
- Receives a UDP packet from a client
- Reads the 8-byte header to identify the sender's
SessionID - Looks up which channel the sender is in
- Forwards the packet as-is to all other members of that channel
- Skips the sender (no echo) and any deafened users
The AES-128-GCM nonce (12 bytes) is deterministic and never reused while a voice key is active:
Nonce = [SessionID (4B)] [SeqNum (4B)] [0x00 0x00 0x00 0x00 (4B)]
SessionIDis allocated from a random starting point and is never issued again during the server lifecycle, even after its session disconnects- the shared voice key is generated for that same server lifecycle, so historical sessions cannot repeat a nonce under the same key
SeqNumstarts at one and increases monotonically per sender; the client refuses to send afteruint32exhaustion and requires a reconnect instead of wrapping to zero
- Port: 9603 (default)
- Transport: Dedicated TCP/TLS connection per authenticated session
- Authentication: Ephemeral
screen_auth_tokenissued inAuthResponse - Encryption: AES-128-GCM with one key per active screen share
- Usage: Low-rate JPEG frames, forwarded only to subscribed viewers
- Client authenticates on the control plane.
- When screen sharing is enabled, the server sets
screen_share_enabledand returnsscreen_addrplus a session-scopedscreen_auth_token. These optional fields are omitted when the feature is disabled. - The client opens the screen-plane TLS connection only when
screen_share_enabledis true, then authenticates with the token. - Screen-share start/stop/subscribe still happen on the control plane.
- Actual encrypted frame packets flow over the screen plane.
The server does not need to decode screen frames. It:
- Authenticates a screen-plane connection against the existing control session.
- Accepts encrypted packets from the active sharer only.
- Looks up the sharer's subscribed viewers.
- Forwards each packet as-is to those viewers.
Each screen packet is length-prefixed on the TCP stream:
[Length:4B][SessionID:4B][SeqNum:4B][Ciphertext+AuthTag]
The AES-GCM additional data is the 8-byte packet header [SessionID|SeqNum]. The encrypted payload contains timestamp, frame dimensions, frame format, and frame bytes.