add docs, protocol, plugins, README, CHANGELOG
This commit is contained in:
parent
1d2209bfac
commit
f262da222b
44 changed files with 9269 additions and 0 deletions
85
docs/02-protocol/README.md
Normal file
85
docs/02-protocol/README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# LNEx Protocol
|
||||
|
||||
> **Phase 1:** JSON over plain TCP, raw Opus over UDP. Wire encryption is Phase 2.
|
||||
> See [00-status.md](../00-status.md).
|
||||
|
||||
LNEx (Loki Network Exchange) is the networking protocol used by VNOX.
|
||||
|
||||
It is a custom application-layer protocol that runs over TCP and UDP.
|
||||
It defines how VNOX clients and servers communicate — packet format,
|
||||
encryption, routing, identity, and federation.
|
||||
|
||||
---
|
||||
|
||||
## Design goals
|
||||
|
||||
- low latency for voice packets (UDP path)
|
||||
- reliable delivery for control messages (TCP path)
|
||||
- encrypted by default in production (Phase 2 target; plaintext in v0.1.x dev builds)
|
||||
- identity without central authority: keypair-based, no email, no server account registry
|
||||
- federation between independent nodes (Phase 3)
|
||||
|
||||
## Non-goals
|
||||
|
||||
- backward compatibility with Discord, TeamSpeak, or Matrix
|
||||
- browser / WebSocket native support (use a gateway adapter if needed)
|
||||
- guaranteed ordering on the UDP voice path
|
||||
|
||||
---
|
||||
|
||||
## Version
|
||||
|
||||
Current version: `LNEx v1`
|
||||
|
||||
Version is negotiated during handshake. Clients and servers advertise
|
||||
their supported versions. If no common version exists, the connection
|
||||
is rejected with `ERR_VERSION_MISMATCH`.
|
||||
|
||||
LNEx versioning is independent from VNOX client and server versioning.
|
||||
A new VNOX release may or may not bump the LNEx version.
|
||||
|
||||
---
|
||||
|
||||
## Transport mapping
|
||||
|
||||
| Path | Transport | Used for |
|
||||
|------|-----------|---------|
|
||||
| Control | TCP | auth, chat, channels, events, federation |
|
||||
| Voice | UDP | audio frames, realtime state |
|
||||
|
||||
Both paths use LNEx packet framing. The packet format is the same;
|
||||
the transport-specific behavior (ordering, retransmit) is left to TCP/UDP.
|
||||
|
||||
---
|
||||
|
||||
## Connection lifecycle
|
||||
|
||||
```
|
||||
Client Gateway
|
||||
│ │
|
||||
│──── TCP connect ───────────────▶│
|
||||
│◀─── HELLO (server pubkey) ──────│
|
||||
│──── AUTH (client pubkey + sig) ─▶│
|
||||
│◀─── SESSION (session_id, token) ─│
|
||||
│ │
|
||||
│ [control channel established] │
|
||||
│ │
|
||||
│──── JOIN_CHANNEL ──────────────▶│
|
||||
│◀─── CHANNEL_STATE ──────────────│
|
||||
│ │
|
||||
│ [UDP voice path] │
|
||||
│──── UDP VOICE_PACKET ──────────▶│ Voice Node
|
||||
│◀─── UDP VOICE_PACKET ───────────│
|
||||
```
|
||||
|
||||
Full state machine: see individual spec files.
|
||||
|
||||
---
|
||||
|
||||
## Sections
|
||||
|
||||
- [packets.md](packets.md) — packet format, base and voice packets, Protobuf schema
|
||||
- [voice-pipeline.md](voice-pipeline.md) — Opus codec, UDP relay, jitter buffer
|
||||
- [federation/README.md](federation/README.md) — node discovery, cross-node routing
|
||||
- [identity.md](identity.md) — keypair model, auth flow, permissions
|
||||
- [security.md](security.md) — encryption, threat model
|
||||
174
docs/02-protocol/federation/README.md
Normal file
174
docs/02-protocol/federation/README.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# Federation
|
||||
|
||||
> Status: Phase 3 — design draft, not yet implemented.
|
||||
> This document describes the intended design. Details may change.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Federation allows independent VNOX nodes to communicate with each other.
|
||||
Users on one node can join channels, send messages, and voice-chat with
|
||||
users on a different node — without either node being subordinate to the other.
|
||||
|
||||
There is no central federation server. Every node is equal.
|
||||
|
||||
---
|
||||
|
||||
## Identity in federation
|
||||
|
||||
Federated identity format:
|
||||
|
||||
```
|
||||
user@node
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
raven@nightcore.lnex
|
||||
0xmist@dev.vnox.io
|
||||
you@192.168.1.10
|
||||
```
|
||||
|
||||
The `node` part is a resolvable address: domain, IP, or LNEx node ID.
|
||||
The `user` part is the short form of the user's pubkey (or their chosen nickname,
|
||||
disambiguated by pubkey if collision).
|
||||
|
||||
---
|
||||
|
||||
## What can be federated
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Text chat bridging | Phase 3 |
|
||||
| Voice relay across nodes | Phase 3 |
|
||||
| Channel sharing | Phase 3 |
|
||||
| Identity sync | Phase 3 |
|
||||
| Permissions across nodes | Phase 4 |
|
||||
| Encrypted federation | Phase 4 |
|
||||
|
||||
---
|
||||
|
||||
## Node discovery
|
||||
|
||||
Three strategies are planned. A node may use any combination.
|
||||
|
||||
### 1. Bootstrap list
|
||||
|
||||
A static list of known nodes in `config.toml`:
|
||||
|
||||
```toml
|
||||
[federation]
|
||||
bootstrap = [
|
||||
"relay.nightcore.lnex",
|
||||
"192.168.1.20:7700",
|
||||
]
|
||||
```
|
||||
|
||||
Simple and predictable. Suitable for private networks.
|
||||
|
||||
### 2. DNS SRV records
|
||||
|
||||
A node publishes its LNEx endpoint via DNS:
|
||||
|
||||
```
|
||||
_lnex._udp.nightcore.lnex. SRV 0 0 7700 nightcore.lnex.
|
||||
```
|
||||
|
||||
Allows discovery via domain name without hardcoded IPs.
|
||||
|
||||
### 3. DHT (future)
|
||||
|
||||
For fully decentralized discovery without any bootstrap list or DNS.
|
||||
Not planned until Phase 4.
|
||||
|
||||
---
|
||||
|
||||
## Federation routing
|
||||
|
||||
When a client on node A wants to reach node B:
|
||||
|
||||
```
|
||||
Client (node A)
|
||||
│
|
||||
▼
|
||||
Gateway A ──── resolves node B address (DNS / bootstrap)
|
||||
│
|
||||
▼
|
||||
LNEx Federation handshake (node A → node B)
|
||||
│ mutual auth: exchange pubkeys
|
||||
│ negotiate: shared channels, relay policy
|
||||
▼
|
||||
Established federation link
|
||||
│
|
||||
├── text: messages forwarded via TCP federation channel
|
||||
└── voice: packets relayed via UDP federation relay path
|
||||
```
|
||||
|
||||
### Federation link lifecycle
|
||||
|
||||
1. Node A initiates connection to node B
|
||||
2. Both nodes authenticate using their node keypairs
|
||||
3. Nodes exchange a list of channels available for bridging
|
||||
4. A federation session is established with a session token
|
||||
5. Session is kept alive with periodic PING/PONG
|
||||
6. On disconnect, pending messages are queued for retry
|
||||
|
||||
---
|
||||
|
||||
## Voice relay across nodes
|
||||
|
||||
```
|
||||
Client A (node A)
|
||||
│ UDP
|
||||
▼
|
||||
Voice Node A
|
||||
│ UDP (federation relay path)
|
||||
▼
|
||||
Voice Node B
|
||||
│ UDP
|
||||
▼
|
||||
Client B (node B)
|
||||
```
|
||||
|
||||
Voice packets are forwarded between voice nodes using the same
|
||||
LNEx UDP packet format. The federation relay path adds one hop
|
||||
and ~1–10ms additional latency depending on network distance.
|
||||
|
||||
---
|
||||
|
||||
## Channel bridging
|
||||
|
||||
A channel can be bridged between two nodes. Bridged channels appear
|
||||
in both node's channel lists. Messages and voice from either side
|
||||
flow through the federation link.
|
||||
|
||||
```toml
|
||||
# On node A config
|
||||
[federation.bridges]
|
||||
"#general" = "nightcore.lnex/#general"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-spam and rate limiting
|
||||
|
||||
Federation links are subject to rate limiting to prevent a rogue node
|
||||
from flooding a target node.
|
||||
|
||||
Limits (configurable):
|
||||
- max federation connections per node: 16
|
||||
- max bridged channels: 64
|
||||
- message rate per federation link: 100/s
|
||||
- voice packet rate per relayed user: standard voice rate
|
||||
|
||||
A node can block or denylist other nodes in `config.toml`.
|
||||
|
||||
---
|
||||
|
||||
## Open questions (TODO)
|
||||
|
||||
- How to handle split-brain: node B is temporarily unreachable during a conversation
|
||||
- Message history sync across federated nodes
|
||||
- Permission model for federated users (what can a `user@external-node` do on your node)
|
||||
- Trust levels: `trusted` / `untrusted` / `blocked` per federation link
|
||||
169
docs/02-protocol/identity.md
Normal file
169
docs/02-protocol/identity.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Identity
|
||||
|
||||
> **Phase 1 note:** the client stores the keypair as plain JSON in the data directory.
|
||||
> Passphrase encryption (Argon2id), seed phrase UI, and keyfile export are Phase 2.
|
||||
|
||||
## Model
|
||||
|
||||
VNOX has no accounts. No email. No phone number. No central registry.
|
||||
|
||||
On first launch, the client generates a keypair locally:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "4f3a8b2c9d1e7a0f3b5c8d2e1a9f4b7c2d8e3f1a",
|
||||
"nickname": "user",
|
||||
"pubkey": "ed25519:4f3a8b2c...",
|
||||
"created_at": 1716000000
|
||||
}
|
||||
```
|
||||
|
||||
The private key never leaves the device (unless explicitly exported by the user).
|
||||
The public key is the identity. Everything — permissions, history, session tokens —
|
||||
is tied to the pubkey.
|
||||
|
||||
---
|
||||
|
||||
## Keypair
|
||||
|
||||
Algorithm: Ed25519
|
||||
|
||||
- fast signature verification
|
||||
- small key size (32 bytes public, 64 bytes private)
|
||||
- widely supported in Rust ecosystem (dalek-cryptography)
|
||||
|
||||
The keypair is stored locally in the client's data directory.
|
||||
|
||||
**Phase 1 (current):** plain JSON file (`identity.json`).
|
||||
|
||||
**Phase 2 (planned):** encrypted at rest with a user-chosen passphrase (Argon2id KDF).
|
||||
|
||||
---
|
||||
|
||||
## Auth flow
|
||||
|
||||
```
|
||||
Client Gateway
|
||||
│ │
|
||||
│── TCP connect ────────────────────▶│
|
||||
│ │
|
||||
│◀── HELLO { server_pubkey, │
|
||||
│ challenge_nonce } ──────│
|
||||
│ │
|
||||
│── AUTH { │
|
||||
│ client_pubkey, │
|
||||
│ nickname, │
|
||||
│ lnex_version, │
|
||||
│ sig: sign(challenge_nonce, │
|
||||
│ client_privkey) │
|
||||
│ } ──────────────────────────────▶│
|
||||
│ │ verify signature
|
||||
│ │ check if banned
|
||||
│◀── SESSION { │
|
||||
│ session_id, │
|
||||
│ token, │
|
||||
│ expires_at │
|
||||
│ } ──────────────────────────────│
|
||||
│ │
|
||||
│ [authenticated] │
|
||||
```
|
||||
|
||||
The gateway verifies the signature against the challenge nonce.
|
||||
If valid, a session token is issued. The token is used for subsequent
|
||||
requests within the TCP session.
|
||||
|
||||
The gateway does not store private keys. It only stores public keys
|
||||
of users who have connected (for banning and permission assignment).
|
||||
|
||||
---
|
||||
|
||||
## Session
|
||||
|
||||
Sessions are in-memory on the gateway. They expire when the TCP connection closes
|
||||
or after a configurable idle timeout.
|
||||
|
||||
There is no persistent login. Each connection requires a fresh auth exchange.
|
||||
The session token is not a password — it proves nothing without the underlying
|
||||
keypair.
|
||||
|
||||
---
|
||||
|
||||
## Nickname
|
||||
|
||||
Nickname is a human-readable label, not unique. Two users may have the same nickname.
|
||||
The pubkey (or its short form) is the unique identifier.
|
||||
|
||||
Nickname is self-asserted and can be changed at any time.
|
||||
Servers may impose length or character limits.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
Permissions are assigned to pubkeys on each node independently.
|
||||
There is no global permission registry.
|
||||
|
||||
Permission levels (example model — configurable per node):
|
||||
|
||||
```
|
||||
guest — read channels, no voice
|
||||
member — read + write + voice
|
||||
moderator — member + kick + mute others + manage channels
|
||||
admin — full control of the node
|
||||
owner — same as admin, cannot be demoted by admins
|
||||
```
|
||||
|
||||
A user's permission level on node A has no bearing on their level on node B.
|
||||
Federated permission model is a Phase 4 design question.
|
||||
|
||||
---
|
||||
|
||||
## Keypair backup
|
||||
|
||||
> **Phase 2.** UI and export flows below are not implemented in v0.1.x.
|
||||
|
||||
If the keypair is lost, the identity is lost. There is no recovery
|
||||
without a backup. This is intentional. There is no central authority
|
||||
to reset your account.
|
||||
|
||||
Two backup methods are planned:
|
||||
|
||||
### Seed phrase
|
||||
|
||||
A 24-word BIP39-compatible mnemonic derived from the private key entropy.
|
||||
|
||||
```
|
||||
word1 word2 word3 ... word24
|
||||
```
|
||||
|
||||
The seed phrase can regenerate the keypair deterministically.
|
||||
Store it offline. Never share it.
|
||||
|
||||
To enable: Settings → Identity → Seed phrase backup → Show phrase.
|
||||
|
||||
### Encrypted keyfile
|
||||
|
||||
Export the keypair as a `.vnox` file, encrypted with a passphrase
|
||||
(AES-256-GCM, passphrase stretched with Argon2id).
|
||||
|
||||
```
|
||||
Settings → Identity → Export keypair → Save as .vnox
|
||||
```
|
||||
|
||||
To restore: launch client → Import keypair → select .vnox file → enter passphrase.
|
||||
|
||||
---
|
||||
|
||||
## Rotating the keypair
|
||||
|
||||
If a keypair is compromised, the user can generate a new one.
|
||||
The old keypair becomes inactive immediately.
|
||||
|
||||
Effects:
|
||||
- new identity on all nodes (the old pubkey is a different person)
|
||||
- permissions tied to old pubkey remain on the server (admins can clean up)
|
||||
- history attributed to old pubkey is not migrated
|
||||
|
||||
Rotation is permanent and cannot be undone.
|
||||
|
||||
`Settings → Identity → Rotate keypair → Confirm`
|
||||
266
docs/02-protocol/packets.md
Normal file
266
docs/02-protocol/packets.md
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
# Packets
|
||||
|
||||
> **Phase 1 note:** TCP control payloads are serialized as **JSON**, not Protobuf.
|
||||
> Protobuf is the Phase 2 target. See [01-architecture.md](../01-architecture.md).
|
||||
|
||||
## Base packet
|
||||
|
||||
All LNEx packets share a common header, regardless of transport.
|
||||
|
||||
```
|
||||
┌──────────────┬──────────────┬──────────────┬────────────────┬─────────┐
|
||||
│ packet_id │ flags │ sequence │ payload_length │ payload │
|
||||
│ (2 bytes) │ (2 bytes) │ (4 bytes) │ (4 bytes) │ (var) │
|
||||
└──────────────┴──────────────┴──────────────┴────────────────┴─────────┘
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
`packet_id` — identifies the packet type. See packet type registry below.
|
||||
|
||||
`flags` — bitmask:
|
||||
|
||||
```
|
||||
bit 0 — COMPRESSED payload is zstd compressed
|
||||
bit 1 — ENCRYPTED payload is encrypted (ChaCha20-Poly1305)
|
||||
bit 2 — FRAGMENTED this is a fragment of a larger payload
|
||||
bit 3 — LAST_FRAG this is the last fragment
|
||||
bit 4 — ACK_REQ sender requests acknowledgement (TCP path only)
|
||||
bit 5-15 — reserved
|
||||
```
|
||||
|
||||
`sequence` — monotonically increasing per-session counter.
|
||||
On the UDP voice path, gaps in sequence indicate packet loss.
|
||||
|
||||
`payload_length` — byte length of the payload that follows.
|
||||
|
||||
`payload` - packet-type-specific data. **Phase 1:** JSON. **Phase 2:** Protobuf (planned).
|
||||
|
||||
---
|
||||
|
||||
## Voice packet
|
||||
|
||||
Voice packets are sent over UDP. They extend the base header with
|
||||
audio-specific fields before the Opus payload.
|
||||
|
||||
```
|
||||
┌──────────────┬──────────────┬──────────────┬────────────────┬────────────────┬────────────┐
|
||||
│ packet_id │ flags │ voice_seq │ timestamp │ channel_id │ opus_data │
|
||||
│ = 0x0010 │ (2 bytes) │ (4 bytes) │ (4 bytes) │ (8 bytes) │ (var) │
|
||||
└──────────────┴──────────────┴──────────────┴────────────────┴────────────────┴────────────┘
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
`voice_seq` — separate sequence counter for the voice stream.
|
||||
Resets per channel join. Used by jitter buffer for reordering.
|
||||
|
||||
`timestamp` — RTP-style timestamp in samples (48000 Hz clock).
|
||||
Used for jitter buffer and playout scheduling.
|
||||
|
||||
`channel_id` — identifies which voice channel this frame belongs to.
|
||||
Allows a single UDP socket to carry multiple channels.
|
||||
|
||||
`opus_data` — raw Opus-encoded frame. Length derived from `payload_length`
|
||||
minus the fixed voice header (16 bytes).
|
||||
|
||||
---
|
||||
|
||||
## Packet type registry
|
||||
|
||||
```
|
||||
0x0001 HELLO server → client on connect
|
||||
0x0002 AUTH client → server, identity + signature
|
||||
0x0003 SESSION server → client, session established
|
||||
0x0004 PING either direction
|
||||
0x0005 PONG reply to PING
|
||||
|
||||
0x0010 VOICE_PACKET UDP, client ↔ voice-node
|
||||
0x0011 VOICE_STATE speaking / silent / muted
|
||||
|
||||
0x0020 CHAT_MESSAGE text message in channel
|
||||
0x0021 CHAT_HISTORY batch of historical messages
|
||||
|
||||
0x0030 JOIN_CHANNEL client → gateway
|
||||
0x0031 LEAVE_CHANNEL client → gateway
|
||||
0x0032 CHANNEL_STATE gateway → client, full channel snapshot
|
||||
|
||||
0x0040 USER_JOIN broadcast to channel members
|
||||
0x0041 USER_LEAVE broadcast to channel members
|
||||
|
||||
0x0050 PERMISSION_CHECK gateway → client
|
||||
0x0051 PERMISSION_DENY gateway → client
|
||||
|
||||
0x0060 DM_START client → gateway, initiate 1:1 DM conversation
|
||||
0x0061 DM_MESSAGE client ↔ gateway, individual DM message
|
||||
0x0062 DM_HISTORY client → gateway, fetch last 50 messages
|
||||
|
||||
0x0100 GUILD_CREATE client → gateway
|
||||
0x0101 GUILD_DELETE client → gateway
|
||||
0x0102 GUILD_LIST client → gateway
|
||||
0x0103 GUILD_MEMBER_JOIN client → gateway
|
||||
0x0104 GUILD_MEMBER_LEAVE client → gateway
|
||||
0x0105 GUILD_MEMBER_KICK client → gateway
|
||||
0x0106 ROLE_CREATE client → gateway
|
||||
0x0107 ROLE_DELETE client → gateway
|
||||
0x0108 INVITE_CREATE client → gateway
|
||||
0x0109 INVITE_ACCEPT client → gateway
|
||||
0x010A INVITE_DELETE client → gateway
|
||||
|
||||
0x0140 PRESENCE_UPDATE client → gateway
|
||||
0x0141 PRESENCE_SYNC gateway → client
|
||||
0x0142 PRESENCE_EVENT gateway → client
|
||||
|
||||
0x0150 FRIEND_REQUEST client → gateway
|
||||
0x0151 FRIEND_ACCEPT client → gateway
|
||||
0x0152 FRIEND_DECLINE client → gateway
|
||||
0x0153 FRIEND_REMOVE client → gateway
|
||||
0x0154 FRIEND_LIST client → gateway
|
||||
0x0155 BLOCK_USER client → gateway
|
||||
0x0156 UNBLOCK_USER client → gateway
|
||||
0x0157 BLOCK_LIST client → gateway
|
||||
|
||||
0x00F0 ERROR any direction, see error codes
|
||||
0x00FF DISCONNECT graceful close
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Direct Messages (Phase 1.1)
|
||||
|
||||
DM support enables 1:1 private messaging with persistent history. DMs are identified by a canonical ID format:
|
||||
`dm_{lexicographically_smaller_uid}_{larger_uid}`. This ensures both participants reference the same conversation.
|
||||
|
||||
### DmStart (0x0060)
|
||||
|
||||
Client initiates or opens an existing DM conversation with another user.
|
||||
|
||||
```json
|
||||
{
|
||||
"target_user_id": "user_pubkey_b64"
|
||||
}
|
||||
```
|
||||
|
||||
Response (server sends back as 0x0060):
|
||||
|
||||
```json
|
||||
{
|
||||
"dm_id": "dm_userid1_userid2",
|
||||
"other_user_id": "user_pubkey_b64",
|
||||
"other_nickname": "Alice",
|
||||
"messages": [
|
||||
{
|
||||
"dm_id": "dm_userid1_userid2",
|
||||
"sender_id": "user_pubkey_b64",
|
||||
"content": "Hello!",
|
||||
"timestamp": 1234567890000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### DmMessage (0x0061)
|
||||
|
||||
Send a new DM or receive one from another user.
|
||||
|
||||
```json
|
||||
{
|
||||
"dm_id": "dm_userid1_userid2",
|
||||
"sender_id": "user_pubkey_b64",
|
||||
"content": "Hello Alice!",
|
||||
"timestamp": 1234567890000
|
||||
}
|
||||
```
|
||||
|
||||
Server-authoritative: sender_id and timestamp are set by the server based on the authenticated session.
|
||||
|
||||
### DmHistory (0x0062)
|
||||
|
||||
Fetch historical messages from a DM.
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"dm_id": "dm_userid1_userid2"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"dm_id": "dm_userid1_userid2",
|
||||
"messages": [
|
||||
{ "dm_id": "...", "sender_id": "...", "content": "...", "timestamp": 1234567890000 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Protobuf schema
|
||||
|
||||
Payload of each packet is a serialized Protobuf message.
|
||||
Schemas live in `protocol/` at the repository root.
|
||||
|
||||
Example — `ChatMessage`:
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
|
||||
message ChatMessage {
|
||||
string message_id = 1; // UUID v4
|
||||
string channel_id = 2;
|
||||
string sender_id = 3; // sender pubkey (short form)
|
||||
string content = 4;
|
||||
int64 timestamp = 5; // Unix ms
|
||||
}
|
||||
```
|
||||
|
||||
Example — `VoiceState`:
|
||||
|
||||
```protobuf
|
||||
message VoiceState {
|
||||
string user_id = 1;
|
||||
string channel_id = 2;
|
||||
|
||||
enum State {
|
||||
SILENT = 0;
|
||||
SPEAKING = 1;
|
||||
MUTED = 2;
|
||||
DEAFENED = 3;
|
||||
}
|
||||
|
||||
State state = 3;
|
||||
}
|
||||
```
|
||||
|
||||
Full schema reference: `protocol/*.proto`
|
||||
|
||||
---
|
||||
|
||||
## Fragmentation
|
||||
|
||||
Payloads larger than 1400 bytes on the UDP path are fragmented.
|
||||
Each fragment carries the same `sequence`, with `FRAGMENTED` and optionally
|
||||
`LAST_FRAG` flags set. The receiver reassembles before passing to the
|
||||
application layer.
|
||||
|
||||
On TCP, fragmentation is not used — TCP handles it at the transport layer.
|
||||
|
||||
---
|
||||
|
||||
## Error codes
|
||||
|
||||
```
|
||||
0x01 ERR_VERSION_MISMATCH unsupported LNEx version
|
||||
0x02 ERR_AUTH_FAILED invalid signature or unknown identity
|
||||
0x03 ERR_SESSION_EXPIRED session token no longer valid
|
||||
0x04 ERR_PERMISSION_DENIED insufficient permissions for action
|
||||
0x05 ERR_CHANNEL_NOT_FOUND channel does not exist on this node
|
||||
0x06 ERR_RATE_LIMITED too many packets in window
|
||||
0x07 ERR_INVALID_PACKET malformed header or payload
|
||||
0x08 ERR_NODE_UNAVAILABLE federation target node unreachable
|
||||
0x09 ERR_GUILD_NOT_FOUND guild does not exist
|
||||
0x0A ERR_BLOCKED you are blocked by this user
|
||||
0xFF ERR_INTERNAL server-side error
|
||||
```
|
||||
150
docs/02-protocol/security.md
Normal file
150
docs/02-protocol/security.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Security
|
||||
|
||||
> **Phase 1.1 status.** TCP traffic is encrypted with ChaCha20-Poly1305 AEAD.
|
||||
> UDP voice path is still plaintext (Phase 2). See
|
||||
> [features/encryption.md](../05-features/encryption.md) for details.
|
||||
|
||||
## Threat model
|
||||
|
||||
VNOX is designed for self-hosted deployments where the node operator is trusted.
|
||||
The threat model covers:
|
||||
|
||||
| Threat | Mitigated by |
|
||||
|--------|--------------|
|
||||
| MITM on client-server connection | LNEx packet encryption (ChaCha20-Poly1305) active on TCP |
|
||||
| MITM on UDP voice path | LNEx packet encryption (Phase 2; not active in v0.1.x) |
|
||||
| Rogue node in federation | Mutual keypair authentication on federation handshake (Phase 3) |
|
||||
| Identity spoofing | Ed25519 signature on every auth (challenge-response) |
|
||||
| Metadata leakage (who talks to whom) | Phase 2 |
|
||||
| Passive voice interception | Packet encryption (Phase 2; not active in v0.1.x) |
|
||||
| Replay attacks | Sequence number + nonce per packet (Phase 2 wire encryption) |
|
||||
| Brute-force on session | Sessions are short-lived, token is not a password |
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Physical access to the server
|
||||
- Compromised node operator (they own the node, this is by design)
|
||||
- E2EE between clients (planned Phase 2, not in v1)
|
||||
- Anonymity / traffic analysis resistance
|
||||
|
||||
---
|
||||
|
||||
## Encryption
|
||||
|
||||
> **Phase 1.1 target.** See [features/encryption.md](../05-features/encryption.md) for the implementation plan.
|
||||
> Phase 1 uses JSON over plain TCP and raw Opus over UDP.
|
||||
|
||||
### LNEx packet encryption
|
||||
|
||||
All LNEx packets (both TCP and UDP paths) are encrypted at the LNEx layer.
|
||||
|
||||
Algorithm: **ChaCha20-Poly1305** (AEAD)
|
||||
|
||||
- ChaCha20 for stream cipher
|
||||
- Poly1305 for authentication tag (16 bytes)
|
||||
- 96-bit nonce, derived from: `session_id || sequence`
|
||||
- Key derived from ECDH exchange during handshake (X25519)
|
||||
|
||||
ChaCha20-Poly1305 is chosen over AES-GCM because:
|
||||
- constant-time on all platforms (no hardware AES requirement)
|
||||
- faster in software on hardware without AES-NI (common on ARM)
|
||||
- simpler nonce management
|
||||
|
||||
### Key exchange
|
||||
|
||||
During the LNEx handshake:
|
||||
|
||||
1. Server sends its ephemeral X25519 public key in HELLO
|
||||
2. Client generates its own ephemeral X25519 keypair
|
||||
3. Both compute the shared secret via X25519 ECDH
|
||||
4. Shared secret is passed through HKDF-SHA256 to derive:
|
||||
- client → server encryption key
|
||||
- server → client encryption key
|
||||
|
||||
Ephemeral keys are discarded after the session. This provides
|
||||
**forward secrecy** — compromising the long-term identity keypair
|
||||
does not expose past sessions.
|
||||
|
||||
### TCP transport
|
||||
|
||||
TCP connections additionally use TLS 1.3.
|
||||
LNEx packet encryption runs inside TLS — defense in depth.
|
||||
|
||||
TLS certificate: self-signed by default, pinned on first connect (TOFU).
|
||||
Node operators may configure a proper CA-signed certificate.
|
||||
|
||||
### UDP transport
|
||||
|
||||
UDP has no TLS. LNEx packet encryption (ChaCha20-Poly1305) is the only
|
||||
protection layer on the voice path. This is standard practice for
|
||||
real-time voice protocols (SRTP, DTLS-SRTP follow the same model).
|
||||
|
||||
---
|
||||
|
||||
## E2EE (Phase 2)
|
||||
|
||||
In v1, encryption is between client and server (node). The node operator
|
||||
can theoretically decrypt voice and text in transit.
|
||||
|
||||
Phase 2 will introduce optional end-to-end encryption for:
|
||||
- direct messages
|
||||
- private channels (opt-in)
|
||||
|
||||
E2EE for voice is significantly harder (requires key distribution to all
|
||||
channel members in real time) and is a Phase 4 design question.
|
||||
|
||||
---
|
||||
|
||||
## Identity verification
|
||||
|
||||
When user A sees a message from `raven@nightcore.lnex`, how do they know
|
||||
it's the same raven they spoke to yesterday?
|
||||
|
||||
In v1: the gateway enforces that a connected user's pubkey matches their
|
||||
asserted identity. The client can verify the server's claim by checking
|
||||
the pubkey shown in the UI against a known value.
|
||||
|
||||
Future: out-of-band key verification (QR code, safety number, similar to Signal).
|
||||
|
||||
---
|
||||
|
||||
## Rate limiting and anti-flood
|
||||
|
||||
Applied at the gateway level:
|
||||
|
||||
| Limit | Default | Configurable |
|
||||
|-------|---------|-------------|
|
||||
| Auth attempts per IP | 5 / minute | yes |
|
||||
| Messages per user per second | 10 | yes |
|
||||
| Voice packet rate per user | ~50/s (20ms frames) | no (codec-determined) |
|
||||
| Federation connection attempts | 3 / minute per remote | yes |
|
||||
| Max concurrent connections per IP | 4 | yes |
|
||||
|
||||
Exceeding a rate limit returns `ERR_RATE_LIMITED` and may trigger a
|
||||
temporary ban depending on node configuration.
|
||||
|
||||
---
|
||||
|
||||
## Node operator notes
|
||||
|
||||
### What the operator can see
|
||||
|
||||
- IP addresses of connected clients
|
||||
- usernames (nicknames) and pubkeys
|
||||
- channel activity (who joined when)
|
||||
- message content (in v1, no E2EE)
|
||||
|
||||
### What the operator cannot do (by design)
|
||||
|
||||
- impersonate a user's identity (requires their private key)
|
||||
- forge signatures on behalf of a user
|
||||
|
||||
### Recommended hardening
|
||||
|
||||
- run gateway behind a reverse proxy (nginx / caddy) for TLS termination
|
||||
- restrict UDP port to known IP ranges if possible
|
||||
- enable fail2ban or equivalent on auth failure logs
|
||||
- back up the node keypair (used for federation identity)
|
||||
- rotate node keypair on suspected compromise
|
||||
|
||||
See `03-server/operations.md` for hardening checklist.
|
||||
217
docs/02-protocol/state-machine.md
Normal file
217
docs/02-protocol/state-machine.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# State Machine
|
||||
|
||||
> **Phase 1 note:** the gateway and voice-node do not share membership state.
|
||||
> The voice node registers a client on the first UDP packet and drops idle addresses
|
||||
> after 30 seconds. Diagrams that show `gateway notify voice node` describe the Phase 2 target.
|
||||
|
||||
Client and server maintain synchronized state machines over the LNEx connection.
|
||||
This document describes the states, transitions, and what triggers them.
|
||||
|
||||
---
|
||||
|
||||
## Client states
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ DISCONNECTED │ ◄─── initial state / after disconnect
|
||||
└────────┬────────┘
|
||||
│ connect(address)
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ CONNECTING │ TCP handshake in progress
|
||||
└────────┬────────┘
|
||||
│ TCP established
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ HANDSHAKING │ HELLO received, sending AUTH
|
||||
└────────┬────────┘
|
||||
│ SESSION received
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ CONNECTED │ ◄─── idle, no channel joined
|
||||
└────────┬────────┘
|
||||
│ join_channel()
|
||||
▼
|
||||
┌─────────────────┐
|
||||
┌────►│ IN_CHANNEL │ text + voice available
|
||||
│ └────────┬────────┘
|
||||
│ │ leave_channel() / switch channel
|
||||
└──────────────┘
|
||||
│ disconnect() / TCP drop
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ RECONNECTING │ exponential backoff (Phase 2)
|
||||
└────────┬────────┘
|
||||
│ max retries exceeded
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ DISCONNECTED │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
Any state can transition to `DISCONNECTED` on:
|
||||
- TCP connection drop
|
||||
- `DISCONNECT` packet received
|
||||
- auth failure (`ERR_AUTH_FAILED`)
|
||||
- version mismatch (`ERR_VERSION_MISMATCH`)
|
||||
|
||||
---
|
||||
|
||||
## Server session states
|
||||
|
||||
Per-client session on the gateway:
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ ACCEPTING │ TCP connection received, sending HELLO
|
||||
└────────┬────────┘
|
||||
│ AUTH received
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ AUTHENTICATING │ verifying Ed25519 signature
|
||||
└────────┬────────┘
|
||||
│ signature valid + not banned
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ ESTABLISHED │ SESSION sent, client is authenticated
|
||||
└────────┬────────┘
|
||||
│ JOIN_CHANNEL received
|
||||
▼
|
||||
┌─────────────────┐
|
||||
┌────►│ IN_CHANNEL │ forwarding messages + voice state
|
||||
│ └────────┬────────┘
|
||||
│ │ LEAVE_CHANNEL / JOIN different channel
|
||||
└──────────────┘
|
||||
│ TCP drop / DISCONNECT / idle timeout
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ TERMINATED │ session cleaned up, resources freed
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Auth failure paths
|
||||
|
||||
```
|
||||
AUTHENTICATING
|
||||
│ invalid signature → send ERR_AUTH_FAILED → TERMINATED
|
||||
│ banned pubkey → send ERR_AUTH_FAILED → TERMINATED
|
||||
│ unsupported version → send ERR_VERSION_MISMATCH → TERMINATED
|
||||
│ rate limit on auth → send ERR_RATE_LIMITED → TERMINATED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Voice session states (per user, on voice-node)
|
||||
|
||||
> **Phase 1:** transition from `INACTIVE` to `JOINING` happens when the first UDP
|
||||
> packet arrives from a client address, not when the gateway sends a signal.
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ INACTIVE │ user not in a voice channel
|
||||
└────────┬────────┘
|
||||
│ first UDP packet from client (Phase 1)
|
||||
│ gateway signals join (Phase 2, not implemented)
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ JOINING │ UDP path being established
|
||||
└────────┬────────┘
|
||||
│ first UDP packet received from client
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ SILENT │ in channel, not transmitting
|
||||
└────────┬────────┘
|
||||
│ voice packets arriving
|
||||
▼
|
||||
┌─────────────────┐
|
||||
┌────►│ SPEAKING │ relaying to other channel members
|
||||
│ └────────┬────────┘
|
||||
│ │ DTX / push-to-talk released / silence
|
||||
└──────────────┘
|
||||
┌─────────────────┐
|
||||
│ MUTED │ server-side mute (moderator action)
|
||||
└────────┬────────┘
|
||||
│ unmuted by moderator
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ SILENT │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
Voice state is broadcast to all channel members as `VOICE_STATE` packets
|
||||
on every transition: `SILENT → SPEAKING`, `SPEAKING → SILENT`, `MUTED`, `DEAFENED`.
|
||||
|
||||
---
|
||||
|
||||
## Channel join sequence (detailed)
|
||||
|
||||
Full flow from `join_channel()` call to receiving audio:
|
||||
|
||||
```
|
||||
Client Gateway Voice Node
|
||||
│ │ │
|
||||
│── JOIN_CHANNEL ─────────►│ │
|
||||
│ │ check permissions │
|
||||
│ │ check channel exists │
|
||||
│◄─ CHANNEL_STATE ─────────│ │
|
||||
│ { members, │ │
|
||||
│ voice_endpoint, │ (Phase 2: gateway │
|
||||
│ channel_id } │ notifies voice node) │
|
||||
│ │ │
|
||||
│ [start UDP] │ │
|
||||
│── UDP VOICE_PACKET ──────────────────────────────── ►│
|
||||
│ │ │ relay to others
|
||||
│◄─ UDP VOICE_PACKET ───────────────────────────────── │
|
||||
│ │ │
|
||||
│◄─ USER_JOIN broadcast ───│ │
|
||||
│ (sent to all members) │ │
|
||||
```
|
||||
|
||||
If `ERR_PERMISSION_DENIED` is returned on `JOIN_CHANNEL`, the client
|
||||
stays in `CONNECTED` state and does not attempt UDP.
|
||||
|
||||
---
|
||||
|
||||
## Reconnect logic (Phase 2)
|
||||
|
||||
On unexpected TCP drop from `IN_CHANNEL` or `CONNECTED`:
|
||||
|
||||
```
|
||||
disconnect detected
|
||||
│
|
||||
▼
|
||||
RECONNECTING state
|
||||
│
|
||||
├── attempt 1: wait 1s
|
||||
├── attempt 2: wait 2s
|
||||
├── attempt 3: wait 4s
|
||||
├── attempt 4: wait 8s
|
||||
├── attempt 5: wait 16s
|
||||
└── attempt 6+: wait 30s (cap)
|
||||
```
|
||||
|
||||
On successful reconnect:
|
||||
- full auth exchange (new session token)
|
||||
- if user was `IN_CHANNEL` before disconnect: auto-rejoin same channel
|
||||
|
||||
On reconnect failure after N attempts (configurable, default 10):
|
||||
- transition to `DISCONNECTED`
|
||||
- notify user in UI
|
||||
|
||||
---
|
||||
|
||||
## Packet validity by state
|
||||
|
||||
Packets received in an unexpected state are dropped with `ERR_INVALID_PACKET`.
|
||||
|
||||
| Packet | Valid in states |
|
||||
|--------|----------------|
|
||||
| `HELLO` | server sends in `ACCEPTING` |
|
||||
| `AUTH` | client sends in `HANDSHAKING` |
|
||||
| `SESSION` | server sends in `AUTHENTICATING` |
|
||||
| `JOIN_CHANNEL` | `CONNECTED`, `IN_CHANNEL` |
|
||||
| `LEAVE_CHANNEL` | `IN_CHANNEL` |
|
||||
| `CHAT_MESSAGE` | `IN_CHANNEL` |
|
||||
| `VOICE_PACKET` | UDP, user in voice channel |
|
||||
| `PING` / `PONG` | any authenticated state |
|
||||
| `DISCONNECT` | any state |
|
||||
197
docs/02-protocol/voice-pipeline.md
Normal file
197
docs/02-protocol/voice-pipeline.md
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# Voice Pipeline
|
||||
|
||||
> **Phase 1 note:** capture, Opus encode/decode, and UDP relay exist in the repo.
|
||||
> RNNoise, echo cancellation, VAD, and jitter buffer integration are Phase 2 or not wired yet.
|
||||
> See [00-status.md](../00-status.md).
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
Microphone
|
||||
│
|
||||
▼
|
||||
PCM Capture (cpal)
|
||||
│ 48000 Hz, mono, f32
|
||||
▼
|
||||
Pre-processing
|
||||
│ noise suppression (RNNoise) - Phase 2
|
||||
│ echo cancellation - Phase 2
|
||||
│ VAD (voice activity detection) - Phase 2
|
||||
▼
|
||||
Opus Encode
|
||||
│ frame: 10 / 20 / 40ms
|
||||
│ bitrate: 8–128 kbps (default 64k)
|
||||
│ mode: VOIP (optimized for speech)
|
||||
▼
|
||||
LNEx Voice Packet
|
||||
│ header + opus_data
|
||||
│ encrypted + compressed (Phase 2; plaintext in v0.1.x)
|
||||
▼
|
||||
UDP → Voice Node
|
||||
│
|
||||
▼
|
||||
Jitter Buffer
|
||||
│ reorder by voice_seq (code exists; not used in relay yet)
|
||||
│ schedule by timestamp
|
||||
│ adaptive size: 20–80ms
|
||||
▼
|
||||
Opus Decode
|
||||
│ packet loss concealment if gap in sequence
|
||||
▼
|
||||
PCM Output
|
||||
│
|
||||
▼
|
||||
Playback (cpal / rodio)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Codec
|
||||
|
||||
### Opus
|
||||
|
||||
VNOX uses Opus exclusively for voice encoding.
|
||||
|
||||
Parameters:
|
||||
|
||||
| Setting | Value | Notes |
|
||||
|---------|-------|-------|
|
||||
| Sample rate | 48000 Hz | Opus native rate |
|
||||
| Channels | 1 (mono) | stereo optional in Phase 2 |
|
||||
| Application | VOIP | optimized for speech, lower complexity |
|
||||
| Bitrate | 8–128 kbps | default 64k, user-configurable |
|
||||
| Frame size | 20ms default | configurable: 10 / 20 / 40ms |
|
||||
| FEC | enabled | forward error correction for packet loss |
|
||||
| DTX | enabled | discontinuous transmission, silence suppression |
|
||||
|
||||
Lower frame size = lower latency, higher CPU and packet rate.
|
||||
Recommended: 20ms for balance, 10ms for ultra-low latency setups.
|
||||
|
||||
### Why Opus
|
||||
|
||||
- royalty-free
|
||||
- outperforms MP3/AAC at low bitrates for speech
|
||||
- built-in packet loss concealment
|
||||
- adaptive bitrate
|
||||
- widely supported (libopus, bindings for every language)
|
||||
|
||||
---
|
||||
|
||||
## Pre-processing
|
||||
|
||||
> **Phase 2.** Not implemented in the current client (`client/src/audio/`).
|
||||
|
||||
Applied before Opus encoding on the capture path (target design).
|
||||
|
||||
### Noise suppression
|
||||
|
||||
Implementation: RNNoise (ML-based, ~2% CPU)
|
||||
Applied to raw PCM before encoding.
|
||||
Configurable: on / off.
|
||||
|
||||
### Echo cancellation
|
||||
|
||||
Removes microphone pickup of speaker output.
|
||||
Implementation: platform AEC or software fallback.
|
||||
Configurable: on / off.
|
||||
|
||||
### Voice activity detection (VAD)
|
||||
|
||||
Detects when the user is speaking to avoid sending silence packets.
|
||||
Used in "voice activity" mode (alternative to push-to-talk).
|
||||
|
||||
Threshold: configurable 0–100%, default 40%.
|
||||
|
||||
In push-to-talk mode, VAD is bypassed — packets are sent only while
|
||||
the hotkey is held.
|
||||
|
||||
DTX in Opus also provides a secondary layer of silence suppression
|
||||
at the encoder level.
|
||||
|
||||
---
|
||||
|
||||
## UDP relay
|
||||
|
||||
### Path
|
||||
|
||||
```
|
||||
Client A ──UDP──▶ Voice Node ──UDP──▶ Client B
|
||||
│
|
||||
──UDP──▶ Client C
|
||||
│
|
||||
──UDP──▶ Client D
|
||||
```
|
||||
|
||||
The voice node receives packets from each speaker and relays them
|
||||
to all other clients in the same channel.
|
||||
|
||||
No mixing is done on the server. Clients receive separate streams
|
||||
per speaker and mix locally. This allows per-speaker volume control
|
||||
on the client side.
|
||||
|
||||
### Direct P2P (future)
|
||||
|
||||
In Phase 4, direct P2P paths may be established between clients
|
||||
to skip the relay hop. The relay remains as fallback.
|
||||
|
||||
---
|
||||
|
||||
## Jitter buffer
|
||||
|
||||
The jitter buffer absorbs network jitter and reorders out-of-order packets
|
||||
before passing them to the decoder.
|
||||
|
||||
### Operation
|
||||
|
||||
1. Packets arrive with `voice_seq` and `timestamp`
|
||||
2. Buffer holds packets for a configurable window
|
||||
3. Packets are released in sequence order at scheduled playout time
|
||||
4. If a packet is missing when due: Opus PLC generates a concealment frame
|
||||
5. If a late packet arrives after playout: discarded
|
||||
|
||||
### Configuration
|
||||
|
||||
| Setting | Default | Range | Notes |
|
||||
|---------|---------|-------|-------|
|
||||
| Buffer size | 40ms | 20–80ms | lower = less latency, more glitches |
|
||||
| Adaptive mode | on | on/off | auto-adjusts based on observed jitter |
|
||||
| Max late tolerance | 80ms | — | packets older than this are discarded |
|
||||
|
||||
Adaptive mode measures jitter over a rolling window and expands/shrinks
|
||||
the buffer target accordingly. On a stable LAN, buffer converges to ~20ms.
|
||||
On a lossy WAN, it may expand to 60–80ms.
|
||||
|
||||
---
|
||||
|
||||
## Packet loss concealment
|
||||
|
||||
When a voice_seq gap is detected, Opus generates a concealment frame
|
||||
using the previous frame's data. This produces a short fade or
|
||||
interpolated audio rather than a click or silence.
|
||||
|
||||
FEC (Forward Error Correction) in Opus encodes redundant data from
|
||||
the previous frame into the current packet. If the previous packet was
|
||||
lost but the current one arrives, the previous frame can be recovered.
|
||||
|
||||
---
|
||||
|
||||
## Latency budget (target)
|
||||
|
||||
```
|
||||
Microphone capture latency ~5ms
|
||||
Pre-processing (RNNoise, AEC) ~2ms
|
||||
Opus encode (20ms frame) ~20ms
|
||||
UDP tx ~1–5ms (local)
|
||||
Voice node relay ~0.5ms
|
||||
UDP rx ~1–5ms (local)
|
||||
Jitter buffer (adaptive) ~20–40ms
|
||||
Opus decode ~1ms
|
||||
Playback buffer ~5ms
|
||||
──────────────────────────────────────
|
||||
Total (local network) ~55–80ms
|
||||
Target (good conditions) < 60ms
|
||||
```
|
||||
|
||||
For ultra-low latency setups (LAN gaming): use 10ms frame size,
|
||||
reduce jitter buffer to 20ms, disable adaptive mode.
|
||||
Expected total: ~35–45ms.
|
||||
Loading…
Add table
Add a link
Reference in a new issue