diff --git a/README.md b/README.md index 0293f64..aa81545 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,186 @@ -# VNOX +# VNOX — Server -Self-hosted realtime voice and chat. Decentralized. Lightweight. Moddable. -Built on LNEx, a custom protocol for low-latency federated communication. +> Self-hosted voice and chat server. No cloud. No tracking. Your hardware, your rules. -Not Discord. Not TeamSpeak. Not cloud. +**Not Discord. Not TeamSpeak. Not someone else's cloud.** + +[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE) +[![Rust](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org/) +[![Status: Phase 1](https://img.shields.io/badge/status-phase%201%20%E2%80%94%20implemented-yellow.svg)](docs/00-status.md) + +--- + +## What is VNOX? + +VNOX is a self-hosted, real-time voice and text communication platform built entirely in Rust. It runs on your own server, uses a custom low-latency protocol (LNEx), and has no dependency on any third-party infrastructure. + +The architecture is deliberately split into two components: + +- **Gateway** — TCP server: authentication, channels, text chat, guilds, roles, permissions, DMs, invites, audit log +- **Voice Node** — UDP relay: Opus audio, jitter buffer, per-channel packet routing + +Both are written in Rust on top of Tokio. The client is a separate native application at [`VNOX-Client/`](../VNOX-Client/). + +--- + +## Why Rust? + +- Tokio async runtime handles thousands of concurrent connections with minimal overhead +- No GC pauses during active voice sessions +- Memory safety without a garbage collector +- Single cross-platform binary — Windows, macOS, Linux + +--- + +## Architecture ``` -vnox://server/channel +┌──────────────────────────────────────────────────────┐ +│ VNOX Client │ +│ (Rust + Slint UI) │ +└──────────────────────┬───────────────────────────────┘ + │ LNEx v1 + ┌──────────┴──────────┐ + │ TCP │ UDP + ▼ ▼ +┌─────────────────────┐ ┌──────────────────────┐ +│ Gateway │ │ Voice Node │ +│ (Rust / Tokio) │ │ (Rust) │ +│ │ │ │ +│ auth (Ed25519) │ │ Opus relay │ +│ channels │ │ jitter buffer │ +│ sessions │ │ packet routing │ +│ guilds, roles │ │ member tracking │ +│ permissions │ └──────────────────────┘ +│ DMs, invites │ +│ audit log │ +│ rate limiting │ +│ Prometheus metrics │ +└──────────┬──────────┘ + │ SQLite + ▼ + ┌──────────┐ + │ Storage │ + └──────────┘ ``` -## Quick links +Full architecture details: [docs/01-architecture.md](docs/01-architecture.md) + +--- + +## Features + +### Implemented +- **Auth:** Ed25519 challenge-response, session tokens, reconnect with backoff +- **Encryption:** ChaCha20-Poly1305 AEAD + X25519 ECDH key exchange + HKDF key derivation +- **Channels:** Create, delete, list; text and voice channel types +- **Text chat:** Persistent history via SQLite, reactions, replies, edit, delete, typing indicators, read receipts +- **Direct Messages:** 1:1 DMs with persistent history, unread badges, search +- **Guilds:** Create, list, delete, settings +- **Roles:** u64 permission bits, channel overrides, owner bypass +- **Invites:** Permanent and temporary, accept/decline +- **Friends:** Requests, accept/decline, Online/All/Pending/Blocked tabs +- **Presence:** Online/Idle/DND/Invisible, custom status text, activity display +- **Rate limiting:** Per-session token bucket on chat + DMs +- **Metrics:** Prometheus (messages, DMs, voice packets, connections, auth failures, guilds, sessions) +- **Admin HTTP:** `GET /health`, `GET /version`, `GET /metrics` +- **Audit log:** All guild mutations logged +- **Identity vault:** Optional Argon2id + ChaCha20-Poly1305 keyfile encryption at rest +- **Keyfile export/import:** Encrypted or plain JSON keyfile with passphrase + +### Planned +- TLS 1.3 on TCP (Phase 2) +- Protobuf wire format (Phase 2) +- PostgreSQL backend (Phase 2) +- Federation protocol (Phase 3) +- Plugin runtime (Phase 3) + +--- + +## Status + +Phase 1 is implemented. Not production ready. + +| Component | Status | +|----------------|------------------------------------------------------| +| Gateway | TCP listener, LNEx handshake, channels, chat, SQLite | +| Voice node | UDP relay, voice packet routing, jitter buffer | +| Desktop client | Slint UI, net layer, audio pipeline (partial) | +| LNEx protocol | Specified and implemented (JSON in Phase 1) | +| Encryption | ChaCha20-Poly1305 AEAD + X25519 ECDH — DONE | +| Federation | Planned (Phase 3) | +| Mobile client | Planned (Phase 3) | + +See [docs/00-status.md](docs/00-status.md) for a full breakdown. + +--- + +## Quick start + +**Requirements:** Rust 1.85+, a running [VNOX Client](../VNOX-Client/) + +```bash +# Terminal 1 — gateway +cargo run -p vnox-gateway -- --config dev/config.toml + +# Terminal 2 — voice node +cargo run -p vnox-voice-node -- --config dev/config.toml +``` + +The client connects to `127.0.0.1:7600` by default. Config reference: [dev/README.md](dev/README.md). + +### Docker + +```bash +docker-compose up +``` + +### Opus on Windows + +`audiopus_sys` builds libopus from source via CMake. CMake 4.x policy flag is already set in `.cargo/config.toml` — no manual steps needed. + +--- + +## Protocol: LNEx + +LNEx is a custom application-layer protocol for low-latency federated communication. Sits above TCP and UDP, defines packet framing, encryption, and routing. + +Phase 1 uses JSON framing; Phase 2 will migrate to Protobuf. + +The specification is **CC0** (public domain) — anyone can implement a compatible client or server. + +Details: [docs/02-protocol/README.md](docs/02-protocol/README.md) + +--- + +## Project structure + +``` +gateway/ # TCP gateway (auth, channels, chat, guilds) + ├── src/ + │ ├── proto/ # LNEx protocol: packets, crypto, framing + │ ├── net/ # I/O, handshake, session state + │ ├── handler/ # Packet handlers (chat, channel, guild, etc.) + │ ├── domain/ # Business logic: auth, storage, rate limiting + │ ├── admin/ # HTTP admin server (health, metrics) + │ └── bootstrap/ # Startup, config, server identity + └── Cargo.toml + +voice-node/ # UDP voice relay + ├── src/ + │ ├── jitter/ # Jitter buffer (adaptive + fixed modes) + │ ├── relay.rs # Per-channel relay, member tracking + │ └── runner.rs # UDP listener loop + └── Cargo.toml + +serverd/ # Unified server daemon (bundles gateway + voice) +docs/ # Documentation +dev/ # Local development config +``` + +--- + +## Links - [Architecture](docs/01-architecture.md) - [Protocol](docs/02-protocol/README.md) @@ -19,56 +190,9 @@ vnox://server/channel - [Contributing](docs/community/contributing.md) - [Changelog](CHANGELOG.md) -## Status - -Phase 1: implemented, not production ready. - -See [docs/00-status.md](docs/00-status.md) for an honest list of what works, -what is only specified on paper, and known gaps. - -| Component | Status | -|----------------|--------| -| Gateway | TCP listener, LNEx handshake, channels, chat, SQLite | -| Voice node | UDP relay, voice packet routing | -| Desktop client | egui UI, net layer, audio pipeline (partial) | -| LNEx protocol | Specified and implemented (JSON in Phase 1) | -| Federation | Planned (Phase 3) | -| Mobile client | Planned (Phase 3) | - -Traffic in v0.1.x is **unencrypted plaintext**. Do not use in production. - -## Running locally - -Requires Rust 1.85+. - -```sh -# terminal 1 - gateway -cargo run -p vnox-gateway -- --config dev/config.toml - -# terminal 2 - voice node -cargo run -p vnox-voice-node -- --config dev/config.toml - -# terminal 3 - client -cargo run -p vnox-client -``` - -The client connects to `127.0.0.1:7600` by default (editable in the UI). -Config details: [dev/README.md](dev/README.md). - -### Opus on Windows - -`audiopus_sys` builds libopus from source via CMake. -CMake 4.x requires a policy flag, already set in `.cargo/config.toml`: - -```toml -[env] -CMAKE_POLICY_VERSION_MINIMUM = "3.5" -``` - -No manual steps needed. +--- ## License -GPL-3.0. See [docs/LICENSE.md](docs/LICENSE.md). - -The LNEx protocol specification is CC0 (public domain). +Server code: **GPL-3.0** — see [LICENSE](LICENSE) +LNEx protocol specification: **CC0** (public domain) diff --git a/gateway/src/domain/channels/mod.rs b/gateway/src/domain/channels/mod.rs index a0d0109..9c94289 100644 --- a/gateway/src/domain/channels/mod.rs +++ b/gateway/src/domain/channels/mod.rs @@ -26,6 +26,7 @@ pub struct Channel { pub id: String, pub name: String, pub kind: ChannelKind, + pub guild_id: Option, pub members: HashSet, } @@ -39,6 +40,7 @@ pub fn new_store() -> ChannelStore { id: "general".into(), name: "general".into(), kind: ChannelKind::Text, + guild_id: None, members: HashSet::new(), }, ); @@ -48,6 +50,7 @@ pub fn new_store() -> ChannelStore { id: "voice".into(), name: "voice".into(), kind: ChannelKind::Voice, + guild_id: None, members: HashSet::new(), }, ); diff --git a/gateway/src/domain/channels/ops.rs b/gateway/src/domain/channels/ops.rs index 0f0238f..2830d34 100644 --- a/gateway/src/domain/channels/ops.rs +++ b/gateway/src/domain/channels/ops.rs @@ -36,6 +36,7 @@ pub async fn create( channel_id: &str, channel_name: &str, kind: ChannelKind, + guild_id: Option, ) -> bool { let mut l = store.write().await; if l.contains_key(channel_id) { @@ -47,6 +48,7 @@ pub async fn create( id: channel_id.to_string(), name: channel_name.to_string(), kind, + guild_id, members: std::collections::HashSet::new(), }, ); diff --git a/gateway/src/domain/storage/mod.rs b/gateway/src/domain/storage/mod.rs index 593eec7..d82c13e 100644 --- a/gateway/src/domain/storage/mod.rs +++ b/gateway/src/domain/storage/mod.rs @@ -60,6 +60,7 @@ impl Storage { id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'text', + guild_id TEXT, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS bans ( @@ -175,6 +176,7 @@ impl Storage { id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'text', + guild_id TEXT, created_at BIGINT NOT NULL ); CREATE TABLE IF NOT EXISTS bans ( @@ -438,19 +440,21 @@ pub struct ChannelRecord { pub id: String, pub name: String, pub kind: String, + pub guild_id: Option, pub created_at: i64, } impl Storage { - pub async fn create_channel(&self, id: &str, name: &str, kind: &str) -> Result { + pub async fn create_channel(&self, id: &str, name: &str, kind: &str, guild_id: Option<&str>) -> Result { match &self.pool { Pool::Sqlite(p) => { let result = sqlx::query( - "INSERT OR IGNORE INTO channels (id, name, kind, created_at) VALUES (?, ?, ?, ?)", + "INSERT OR IGNORE INTO channels (id, name, kind, guild_id, created_at) VALUES (?, ?, ?, ?, ?)", ) .bind(id) .bind(name) .bind(kind) + .bind(guild_id) .bind(now_ms()) .execute(p) .await?; @@ -458,11 +462,12 @@ impl Storage { } Pool::Postgres(p) => { let result = sqlx::query( - "INSERT INTO channels (id, name, kind, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING", + "INSERT INTO channels (id, name, kind, guild_id, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO NOTHING", ) .bind(id) .bind(name) .bind(kind) + .bind(guild_id) .bind(now_ms()) .execute(p) .await?; @@ -514,15 +519,15 @@ impl Storage { pub async fn list_channels(&self) -> Result> { let rows = match &self.pool { Pool::Sqlite(p) => { - sqlx::query_as::<_, (String, String, String, i64)>( - "SELECT id, name, kind, created_at FROM channels", + sqlx::query_as::<_, (String, String, String, Option, i64)>( + "SELECT id, name, kind, guild_id, created_at FROM channels", ) .fetch_all(p) .await? } Pool::Postgres(p) => { - sqlx::query_as::<_, (String, String, String, i64)>( - "SELECT id, name, kind, created_at FROM channels", + sqlx::query_as::<_, (String, String, String, Option, i64)>( + "SELECT id, name, kind, guild_id, created_at FROM channels", ) .fetch_all(p) .await? @@ -530,10 +535,11 @@ impl Storage { }; Ok(rows .into_iter() - .map(|(id, name, kind, created_at)| ChannelRecord { + .map(|(id, name, kind, guild_id, created_at)| ChannelRecord { id, name, kind, + guild_id, created_at, }) .collect()) @@ -546,7 +552,7 @@ impl Storage { "voice" => ChannelKind::Voice, _ => ChannelKind::Text, }; - crate::domain::channels::create(channel_store, &ch.id, &ch.name, kind).await; + crate::domain::channels::create(channel_store, &ch.id, &ch.name, kind, ch.guild_id).await; } Ok(()) } diff --git a/gateway/src/handler/channel/create.rs b/gateway/src/handler/channel/create.rs index b4365c2..0bad41a 100644 --- a/gateway/src/handler/channel/create.rs +++ b/gateway/src/handler/channel/create.rs @@ -96,14 +96,16 @@ pub async fn handle_channel_create( return Ok(()); } - let created = channels::create(&state.channels, &channel_id, &channel_name, kind.clone()).await; + let guild_id = req.guild_id.clone(); + + let created = channels::create(&state.channels, &channel_id, &channel_name, kind.clone(), guild_id.clone()).await; // Persist to DB (best-effort, log and continue on failure). #[allow(clippy::collapsible_if)] if created { if let Err(e) = state .storage - .create_channel(&channel_id, &channel_name, kind.as_str()) + .create_channel(&channel_id, &channel_name, kind.as_str(), guild_id.as_deref()) .await { warn!("failed to persist channel to storage: {e}"); @@ -140,6 +142,7 @@ pub async fn handle_channel_create( kind: kind.as_str().into(), members: Vec::new(), voice_endpoint: state.config.voice.bind.clone(), + guild_id: req.guild_id.clone(), }; io::send_encrypted( stream, @@ -249,25 +252,45 @@ pub async fn handle_channel_delete( Ok(()) } -/// Handle a ChannelList request — reply with all known channels. +/// Handle a ChannelList request — reply with known channels, optionally filtered by guild_id. pub async fn handle_channel_list( stream: &mut (impl AsyncRead + AsyncWrite + Unpin), seq: &mut u32, session_id: &str, + payload: &[u8], crypto: &SessionCrypto, state: &State, ) -> Result<()> { let _sess = session::get(&state.sessions, session_id).await; + + // Decode optional guild_id filter from request. + let filter_guild_id = if !payload.is_empty() { + let req = ChannelListPayload::decode(payload).ok(); + req.and_then(|r| r.guild_id) + } else { + None + }; + let channels = channels::list(&state.channels).await; let items: Vec = channels .iter() + .filter(|c| { + filter_guild_id + .as_ref() + .map(|gid| c.guild_id.as_deref() == Some(gid.as_str())) + .unwrap_or(true) + }) .map(|c| ChannelListItem { channel_id: c.id.clone(), channel_name: c.name.clone(), kind: c.kind.as_str().into(), + guild_id: c.guild_id.clone(), }) .collect(); - let p = ChannelListPayload { channels: items }; + let p = ChannelListPayload { + channels: items, + guild_id: None, + }; io::send_encrypted(stream, PacketId::ChannelList, seq, &to_payload(&p), crypto).await?; Ok(()) } diff --git a/gateway/src/handler/channel/join.rs b/gateway/src/handler/channel/join.rs index a5cf975..828f4fd 100644 --- a/gateway/src/handler/channel/join.rs +++ b/gateway/src/handler/channel/join.rs @@ -80,6 +80,7 @@ pub async fn join( kind: ch.kind.as_str().into(), members, voice_endpoint: state.config.voice.bind.clone(), + guild_id: ch.guild_id.clone(), }; io::send_encrypted( stream, diff --git a/gateway/src/handler/dispatch.rs b/gateway/src/handler/dispatch.rs index a536bc7..b24d1d3 100644 --- a/gateway/src/handler/dispatch.rs +++ b/gateway/src/handler/dispatch.rs @@ -78,8 +78,15 @@ pub async fn dispatch( .await?; } PacketId::ChannelList => { - channel::handle_channel_list(ctx.stream, ctx.seq, session_id, ctx.crypto, ctx.state) - .await?; + channel::handle_channel_list( + ctx.stream, + ctx.seq, + session_id, + payload, + ctx.crypto, + ctx.state, + ) + .await?; } PacketId::ChatMessage => { let m = ChatMessagePayload::decode(payload)?; diff --git a/gateway/src/lib.rs b/gateway/src/lib.rs index 0ae185d..0e4577e 100644 --- a/gateway/src/lib.rs +++ b/gateway/src/lib.rs @@ -17,6 +17,14 @@ use domain::{channels, config, session, storage}; use net::state::{BroadcastMsg, State, VoiceMemberTx}; use proto::{PacketId, PresenceEventPayload, PresenceInfo, encode_packet, to_payload}; +struct ConnGuard(Arc); + +impl Drop for ConnGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + } +} + pub async fn run(cfg: Arc, voice_member_tx: Option) -> Result<()> { let private_mode = cfg.is_private(); if private_mode { @@ -28,8 +36,10 @@ pub async fn run(cfg: Arc, voice_member_tx: Option { @@ -114,13 +124,19 @@ pub async fn run(cfg: Arc, voice_member_tx: Option, state: State) -> Result<()> { +async fn run_tls( + listener: TcpListener, + cfg: Arc, + state: State, + max_connections: Option, + conn_count: Arc, +) -> Result<()> { let (certs, key) = load_or_generate_tls_certs(&cfg.gateway)?; if cfg.gateway.tls_cert_path.is_none() { warn!("using self-signed TLS certificate — clients must accept it manually"); @@ -133,11 +149,20 @@ async fn run_tls(listener: TcpListener, cfg: Arc, state: State) loop { match listener.accept().await { Ok((stream, addr)) => { + let current = conn_count.load(std::sync::atomic::Ordering::Relaxed); + if let Some(limit) = max_connections && current >= limit { + warn!("connection rejected from {addr}: at max_connections ({limit})"); + drop(stream); + continue; + } + conn_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); info!("connection from {addr} (TLS)"); state.metrics.inc(&state.metrics.connections_total); let s = state.clone(); let acceptor = acceptor.clone(); + let conn_count_clone = conn_count.clone(); tokio::spawn(async move { + let _guard = ConnGuard(conn_count_clone); match acceptor.accept(stream).await { Ok(tls_stream) => { if let Err(e) = handle(tls_stream, addr, s).await { @@ -153,14 +178,28 @@ async fn run_tls(listener: TcpListener, cfg: Arc, state: State) } } -async fn run_plain(listener: TcpListener, state: State) -> Result<()> { +async fn run_plain( + listener: TcpListener, + state: State, + max_connections: Option, + conn_count: Arc, +) -> Result<()> { loop { match listener.accept().await { Ok((stream, addr)) => { + let current = conn_count.load(std::sync::atomic::Ordering::Relaxed); + if let Some(limit) = max_connections && current >= limit { + warn!("connection rejected from {addr}: at max_connections ({limit})"); + drop(stream); + continue; + } + conn_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); info!("connection from {addr}"); state.metrics.inc(&state.metrics.connections_total); let s = state.clone(); + let conn_count_clone = conn_count.clone(); tokio::spawn(async move { + let _guard = ConnGuard(conn_count_clone); if let Err(e) = handle(stream, addr, s).await { debug!("{addr} closed: {e}"); } diff --git a/protocol/lnex.proto b/protocol/lnex.proto index 5176f3b..0bc4419 100644 --- a/protocol/lnex.proto +++ b/protocol/lnex.proto @@ -47,6 +47,7 @@ message ChannelState { string kind = 3; repeated MemberInfo members = 4; string voice_endpoint = 5; + optional string guild_id = 6; } message MemberInfo { @@ -69,12 +70,16 @@ message ChannelEdit { string channel_name = 2; } -message ChannelList { repeated ChannelListItem channels = 1; } +message ChannelList { + repeated ChannelListItem channels = 1; + optional string guild_id = 2; +} message ChannelListItem { - string channel_id = 1; - string channel_name = 2; - string kind = 3; + string channel_id = 1; + string channel_name = 2; + string kind = 3; + optional string guild_id = 4; } message UserJoin { string channel_id = 1; string user_id = 2; string nickname = 3; } diff --git a/voice-node/src/relay.rs b/voice-node/src/relay.rs index 7d4982a..1423f5f 100644 --- a/voice-node/src/relay.rs +++ b/voice-node/src/relay.rs @@ -123,3 +123,97 @@ pub async fn remove_member_from_all(channels: &ChannelMap, addr: &SocketAddr) { !state.members.is_empty() }); } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::SocketAddr; + use std::str::FromStr; + + fn test_addr(n: u8) -> SocketAddr { + SocketAddr::from_str(&format!("127.0.0.{n}:5000")).unwrap() + } + + #[tokio::test] + async fn push_and_touch_creates_channel() { + let channels = new_channel_map(); + let addr = test_addr(1); + touch_member(&channels, 42, addr).await; + { + let lock = channels.read().await; + let state = lock.get(&42).unwrap(); + assert!(state.members.contains_key(&addr)); + assert!(state.jitter.is_empty()); + } + } + + #[tokio::test] + async fn push_packet_adds_to_jitter() { + let channels = new_channel_map(); + let addr = test_addr(1); + touch_member(&channels, 7, addr).await; + push_packet(&channels, 7, addr, 1, 1000, b"opus data", 0).await; + { + let lock = channels.read().await; + let state = lock.get(&7).unwrap(); + assert_eq!(state.jitter.len(), 1); + } + } + + #[tokio::test] + async fn remove_member_cleans_up_empty_channel() { + let channels = new_channel_map(); + let addr = test_addr(1); + touch_member(&channels, 99, addr).await; + remove_member_from_all(&channels, &addr).await; + { + let lock = channels.read().await; + assert!(lock.is_empty()); + } + } + + #[tokio::test] + async fn remove_member_keeps_non_empty_channel() { + let channels = new_channel_map(); + let addr1 = test_addr(1); + let addr2 = test_addr(2); + touch_member(&channels, 99, addr1).await; + touch_member(&channels, 99, addr2).await; + remove_member_from_all(&channels, &addr1).await; + { + let lock = channels.read().await; + let state = lock.get(&99).unwrap(); + assert!(!state.members.contains_key(&addr1)); + assert!(state.members.contains_key(&addr2)); + } + } + + #[tokio::test] + async fn cleanup_stale_removes_idle_members() { + let channels = new_channel_map(); + let addr = test_addr(1); + touch_member(&channels, 5, addr).await; + cleanup_stale(&channels, Duration::ZERO).await; + { + let lock = channels.read().await; + assert!(lock.is_empty()); + } + } + + #[test] + fn voice_header_parse_valid() { + let mut buf = vec![0u8; crate::VOICE_HDR_SIZE]; + buf[0..2].copy_from_slice(&10u16.to_be_bytes()); + buf[4..8].copy_from_slice(&42u32.to_be_bytes()); + buf[12..20].copy_from_slice(&7u64.to_be_bytes()); + let hdr = crate::VoiceHeader::parse(&buf).unwrap(); + assert_eq!(hdr.packet_id, 10); + assert_eq!(hdr.voice_seq, 42); + assert_eq!(hdr.channel_id, 7); + } + + #[test] + fn voice_header_parse_too_short() { + assert!(crate::VoiceHeader::parse(&[0; 10]).is_none()); + } +}