292 lines
14 KiB
Markdown
292 lines
14 KiB
Markdown
|
|
# Control Protocol
|
|||
|
|
|
|||
|
|
The control plane runs over **TCP, wrapped in TLS 1.3**. It carries everything that is not
|
|||
|
|
real-time media: handshake, authentication, channel/user/presence state, text chat, and
|
|||
|
|
voice *signaling* (announcing that a media stream is starting/stopping). Real-time voice
|
|||
|
|
travels separately over UDP — see [voice.md](voice.md).
|
|||
|
|
|
|||
|
|
## 1. Framing
|
|||
|
|
|
|||
|
|
Inside the TLS stream, messages are length-prefixed:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌──────────────┬───────────────────────────────────────────────┐
|
|||
|
|
│ u32 length │ protobuf-encoded Envelope (length bytes) │
|
|||
|
|
│ (big-endian)│ │
|
|||
|
|
└──────────────┴───────────────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- `length` is the byte count of the payload that follows (not including the 4 length
|
|||
|
|
bytes). Hard cap (e.g. 16 MiB) to bound memory; oversized frame → protocol error +
|
|||
|
|
disconnect.
|
|||
|
|
- The payload is a single **`Envelope`** protobuf message. We do **not** add our own type
|
|||
|
|
byte; the type is the `oneof` discriminator inside the Envelope, which keeps the framing
|
|||
|
|
trivial and lets protobuf own all forward/backward compatibility.
|
|||
|
|
|
|||
|
|
TLS already provides record framing, integrity, and ordering; we only add message
|
|||
|
|
boundaries on top of the TLS byte stream.
|
|||
|
|
|
|||
|
|
## 2. Why Protocol Buffers for control
|
|||
|
|
|
|||
|
|
- Schema-driven codegen for **C++, C#, and Swift** (all first-class) → no hand-rolled
|
|||
|
|
parsers, no drift between client and server.
|
|||
|
|
- **Forward/backward compatible by construction**: unknown fields are preserved/ignored,
|
|||
|
|
new fields and new `oneof` arms are additive. This is exactly the "extensible protocol"
|
|||
|
|
requirement.
|
|||
|
|
- Compact enough for a control plane (text/state, not media). We use **`oneof`** envelopes
|
|||
|
|
rather than `Any` so the wire stays tight and the switch is exhaustive.
|
|||
|
|
|
|||
|
|
> Media frames do **not** use protobuf — they use a fixed binary header (see voice.md),
|
|||
|
|
> because per-packet protobuf overhead and allocation are unacceptable on the RT path.
|
|||
|
|
|
|||
|
|
## 3. The Envelope
|
|||
|
|
|
|||
|
|
```proto
|
|||
|
|
syntax = "proto3";
|
|||
|
|
package voicecat.v1;
|
|||
|
|
|
|||
|
|
message Envelope {
|
|||
|
|
// Monotonic per-connection id set by the sender of a request; echoed in the
|
|||
|
|
// matching response so async callers can correlate. 0 for unsolicited events.
|
|||
|
|
uint64 request_id = 1;
|
|||
|
|
|
|||
|
|
oneof body {
|
|||
|
|
// ── Session / handshake ───────────────────────────────
|
|||
|
|
ClientHello client_hello = 10;
|
|||
|
|
ServerHello server_hello = 11;
|
|||
|
|
AuthRequest auth_request = 12;
|
|||
|
|
AuthResult auth_result = 13;
|
|||
|
|
Disconnect disconnect = 14;
|
|||
|
|
Ping ping = 15;
|
|||
|
|
Pong pong = 16;
|
|||
|
|
|
|||
|
|
// ── State sync ────────────────────────────────────────
|
|||
|
|
ServerStateSnapshot server_state = 20;
|
|||
|
|
ChannelEvent channel_event = 21; // created/updated/deleted
|
|||
|
|
UserEvent user_event = 22; // joined/left/updated
|
|||
|
|
SubscribeRequest subscribe = 23;
|
|||
|
|
|
|||
|
|
// ── Channel operations ────────────────────────────────
|
|||
|
|
JoinChannelRequest join_channel = 30;
|
|||
|
|
JoinChannelResult join_channel_result= 31;
|
|||
|
|
LeaveChannelRequest leave_channel = 32;
|
|||
|
|
CreateChannelRequest create_channel = 33;
|
|||
|
|
EditChannelRequest edit_channel = 34;
|
|||
|
|
DeleteChannelRequest delete_channel = 35;
|
|||
|
|
MoveUserRequest move_user = 36;
|
|||
|
|
GenericResult generic_result = 37; // ack/err for the above
|
|||
|
|
|
|||
|
|
// ── Voice signaling (media is on UDP) ─────────────────
|
|||
|
|
StreamAnnounce stream_announce = 40;
|
|||
|
|
StreamAnnounceResult stream_announce_result = 41;
|
|||
|
|
StreamStop stream_stop = 42;
|
|||
|
|
StreamStateUpdate stream_state = 43; // talking/muted indicator
|
|||
|
|
UdpBinding udp_binding = 44; // token to bind the UDP 5-tuple
|
|||
|
|
|
|||
|
|
// ── Text ──────────────────────────────────────────────
|
|||
|
|
TextMessage text_message = 50;
|
|||
|
|
TextMessageAck text_message_ack = 51;
|
|||
|
|
TypingIndicator typing = 52;
|
|||
|
|
|
|||
|
|
// ── Moderation / permissions ──────────────────────────
|
|||
|
|
KickRequest kick = 60;
|
|||
|
|
BanRequest ban = 61;
|
|||
|
|
SetPermissionRequest set_permission = 62;
|
|||
|
|
|
|||
|
|
// ── Admin account management (privileged) ─────────────
|
|||
|
|
// Accounts are admin-provisioned (no self-serve registration in v1).
|
|||
|
|
// These ride the same TLS control channel and require an admin permission.
|
|||
|
|
CreateAccountRequest create_account = 70;
|
|||
|
|
ResetPasswordRequest reset_password = 71;
|
|||
|
|
DeleteAccountRequest delete_account = 72;
|
|||
|
|
ListAccountsRequest list_accounts = 73;
|
|||
|
|
|
|||
|
|
// ── Extension escape hatch ────────────────────────────
|
|||
|
|
Extension extension = 200; // {string ns; bytes payload;}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Reserved tag ranges keep future families from colliding: **10–19** session, **20–29** state,
|
|||
|
|
**30–39** channels, **40–49** voice signaling, **50–59** text, **60–99** moderation,
|
|||
|
|
**100–199** future (e.g. file transfer = 100–109), **200+** extensions.
|
|||
|
|
|
|||
|
|
## 4. Connection lifecycle
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Client Server
|
|||
|
|
│ TCP connect ───────────────────────────────────▶│
|
|||
|
|
│ ◀──────────────── TLS 1.3 handshake ────────────▶│ server cert (TOFU/PKI, see security.md)
|
|||
|
|
│ │
|
|||
|
|
│ ClientHello (proto_version, features[], info) ──▶│
|
|||
|
|
│ ◀── ServerHello (proto_version, features[], │ feature intersection negotiated here
|
|||
|
|
│ server_info, auth_methods, udp_port) │
|
|||
|
|
│ │
|
|||
|
|
│ AuthRequest (guest{nick} | user{name,pass}) ────▶│ password verified w/ Argon2id
|
|||
|
|
│ ◀── AuthResult (ok, session_id, self, perms, │
|
|||
|
|
│ udp_token) │
|
|||
|
|
│ │
|
|||
|
|
│ ◀── ServerStateSnapshot (channel tree, users) ───│ initial sync
|
|||
|
|
│ │
|
|||
|
|
│ ===== UDP side (parallel) ===================== │
|
|||
|
|
│ (media keys derived from TLS exporter — no 2nd │
|
|||
|
|
│ handshake; see security.md §2) │
|
|||
|
|
│ UdpBinding(udp_token) [AEAD, exported keys] ────▶│ binds 5-tuple → session_id
|
|||
|
|
│ ◀── UdpBinding ack [AEAD] ───────────────────────│
|
|||
|
|
│ │
|
|||
|
|
│ JoinChannelRequest(id, password?) ──────────────▶│
|
|||
|
|
│ ◀── JoinChannelResult(ok, members, audio_cfg) ───│
|
|||
|
|
│ StreamAnnounce(kind=mic, opus_params) ──────────▶│
|
|||
|
|
│ ◀── StreamAnnounceResult(ok, ssrc) │
|
|||
|
|
│ ── voice frames flow over UDP ──────────────────▶│
|
|||
|
|
│ │
|
|||
|
|
│ Ping / Pong (TCP keepalive) ◀──────────────────▶│
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Notes:
|
|||
|
|
|
|||
|
|
- **Version negotiation.** Each side sends `proto_version` (integer) and a `features`
|
|||
|
|
string list. The effective version is `min(client, server)`; the effective feature set
|
|||
|
|
is the intersection. A client that doesn't understand a feature simply never uses it.
|
|||
|
|
- **Auth over TLS.** Passwords cross the wire only inside TLS 1.3 and are verified against
|
|||
|
|
an Argon2id hash at rest (see security.md). `auth_methods` in `ServerHello` advertises
|
|||
|
|
whether `guest` is enabled.
|
|||
|
|
- **UDP token.** `AuthResult.udp_token` is a short-lived opaque token. The client sends it
|
|||
|
|
in the first UDP message (`UdpBinding`) so the server can bind the UDP 5-tuple to the
|
|||
|
|
authenticated session without trusting the source address. This is the only UDP message
|
|||
|
|
that carries identity material; everything after is implicit via the bound tuple +
|
|||
|
|
media-AEAD session.
|
|||
|
|
- **Snapshot then deltas.** After auth the server pushes a `ServerStateSnapshot` (full
|
|||
|
|
channel tree + visible users), then streams incremental `ChannelEvent`/`UserEvent`
|
|||
|
|
deltas. Clients reconcile by id.
|
|||
|
|
|
|||
|
|
## 5. Message catalog (selected definitions)
|
|||
|
|
|
|||
|
|
Representative messages; the full `.proto` is the source of truth in `core/proto/`.
|
|||
|
|
|
|||
|
|
```proto
|
|||
|
|
message ClientHello {
|
|||
|
|
uint32 proto_version = 1;
|
|||
|
|
repeated string features = 2; // e.g. "opus", "fec", "screen-audio"
|
|||
|
|
string client_name = 3; // "VoiceCat-macOS"
|
|||
|
|
string client_version = 4;
|
|||
|
|
string preferred_locale = 5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message ServerHello {
|
|||
|
|
uint32 proto_version = 1;
|
|||
|
|
repeated string features = 2;
|
|||
|
|
string server_name = 3;
|
|||
|
|
string server_version = 4;
|
|||
|
|
repeated string auth_methods = 5; // "guest", "password"
|
|||
|
|
uint32 udp_port = 6;
|
|||
|
|
bytes server_identity_fingerprint = 7; // Ed25519 key fp for TOFU display
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message AuthRequest {
|
|||
|
|
oneof method {
|
|||
|
|
GuestAuth guest = 1; // { string nickname; }
|
|||
|
|
PasswordAuth password = 2; // { string username; string password; }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message AuthResult {
|
|||
|
|
bool ok = 1;
|
|||
|
|
string error = 2;
|
|||
|
|
uint64 session_id = 3;
|
|||
|
|
User self = 4;
|
|||
|
|
Permissions permissions = 5;
|
|||
|
|
bytes udp_token = 6; // bind UDP 5-tuple with this
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message Channel {
|
|||
|
|
uint32 id = 1;
|
|||
|
|
uint32 parent_id = 2; // 0 = root
|
|||
|
|
string name = 3;
|
|||
|
|
string topic = 4;
|
|||
|
|
bool password_protected = 5;
|
|||
|
|
uint32 max_users = 6;
|
|||
|
|
ChannelType type = 7; // PERMANENT / TEMPORARY
|
|||
|
|
AudioConfig audio = 8; // per-channel Opus settings (see voice.md)
|
|||
|
|
int32 order = 9;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message User {
|
|||
|
|
uint32 id = 1;
|
|||
|
|
string nickname = 2;
|
|||
|
|
bool is_guest = 3;
|
|||
|
|
uint32 channel_id = 4;
|
|||
|
|
bool self_mic_muted = 5;
|
|||
|
|
bool self_deafened = 6;
|
|||
|
|
bool server_muted = 7;
|
|||
|
|
repeated StreamInfo streams = 8; // active media streams this user publishes
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message StreamInfo {
|
|||
|
|
uint32 stream_id = 1; // unique within the user
|
|||
|
|
uint32 ssrc = 2; // media-plane id assigned by server
|
|||
|
|
StreamKind kind = 3; // MIC / SCREEN_AUDIO / AUX_DEVICE
|
|||
|
|
AudioConfig audio = 4;
|
|||
|
|
string label = 5; // "Microphone", "Desktop audio"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message StreamAnnounce { // client → server: "I'm about to publish media"
|
|||
|
|
StreamKind kind = 1;
|
|||
|
|
AudioConfig requested_audio = 2; // server may clamp to channel policy
|
|||
|
|
string label = 3;
|
|||
|
|
}
|
|||
|
|
message StreamAnnounceResult {
|
|||
|
|
bool ok = 1; string error = 2;
|
|||
|
|
uint32 stream_id = 3; uint32 ssrc = 4;
|
|||
|
|
AudioConfig effective_audio = 5; // authoritative params to encode with
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message TextMessage {
|
|||
|
|
TextScope scope = 1; // CHANNEL / PRIVATE / SERVER
|
|||
|
|
uint32 target_id = 2; // channel_id or user_id depending on scope
|
|||
|
|
uint32 sender_id = 3; // set by server on relay
|
|||
|
|
string body = 4; // UTF-8, server-bounded length
|
|||
|
|
uint64 sent_at_unix_ms = 5; // server timestamp on relay
|
|||
|
|
string client_msg_id = 6; // client-chosen, echoed in ack (dedup)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> **Text is ephemeral (v1).** The server relays messages live to currently-connected,
|
|||
|
|
> subscribed recipients and **does not persist history** — there is no store and no backfill
|
|||
|
|
> on join. Clients may keep their own local scrollback for the session. Server-side history
|
|||
|
|
> is a deliberate non-feature for now (it can be added later behind a capability flag without
|
|||
|
|
> changing `TextMessage`).
|
|||
|
|
|
|||
|
|
## 6. Request / response & errors
|
|||
|
|
|
|||
|
|
- Any message a client expects a direct answer to sets a nonzero **`request_id`**; the
|
|||
|
|
server echoes it in the response (`*Result` or `GenericResult`). Unsolicited
|
|||
|
|
server→client events use `request_id = 0`.
|
|||
|
|
- **`GenericResult { bool ok; uint32 code; string message; }`** is the default
|
|||
|
|
acknowledgement for operations without a richer reply (create/edit/delete channel, move
|
|||
|
|
user, etc.). Error `code`s are an enumerated, stable list.
|
|||
|
|
- Fatal conditions send **`Disconnect { code; reason }`** then close the TLS connection.
|
|||
|
|
|
|||
|
|
## 7. Keepalive & timeouts
|
|||
|
|
|
|||
|
|
- **TCP:** `Ping`/`Pong` every ~15 s; missing N consecutive pongs → drop. `Pong` echoes the
|
|||
|
|
`Ping` nonce so RTT is measurable.
|
|||
|
|
- **UDP:** a separate lightweight keepalive on the media channel (voice.md §6) keeps NAT
|
|||
|
|
bindings alive and detects media-path failure independently of the control channel.
|
|||
|
|
|
|||
|
|
## 8. Extensibility checklist
|
|||
|
|
|
|||
|
|
When adding a feature later (e.g. **file transfer**), the rules are:
|
|||
|
|
|
|||
|
|
1. Add new `oneof` arms in the reserved tag range (file transfer = 100–109) — never reuse
|
|||
|
|
or renumber existing tags.
|
|||
|
|
2. Advertise a feature string in `ClientHello`/`ServerHello`; only use the feature if both
|
|||
|
|
peers list it.
|
|||
|
|
3. Prefer extending an existing message with new fields (additive) over inventing a new
|
|||
|
|
message where it fits.
|
|||
|
|
4. For experimental/out-of-tree features, ride inside `Extension { ns; payload }` until it
|
|||
|
|
is promoted to a first-class `oneof` arm.
|
|||
|
|
|
|||
|
|
This guarantees a v1 client and a v3 server interoperate at the negotiated lowest common
|
|||
|
|
denominator.
|