docs: rewrite README with architecture, feature list, badges; fix encryption status; add voice-node relay tests; fix clippy warnings
This commit is contained in:
parent
447d65df1b
commit
84df32a9de
10 changed files with 385 additions and 81 deletions
236
README.md
236
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)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](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)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ pub struct Channel {
|
|||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: ChannelKind,
|
||||
pub guild_id: Option<String>,
|
||||
pub members: HashSet<String>,
|
||||
}
|
||||
|
||||
|
|
@ -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(),
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ pub async fn create(
|
|||
channel_id: &str,
|
||||
channel_name: &str,
|
||||
kind: ChannelKind,
|
||||
guild_id: Option<String>,
|
||||
) -> 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(),
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub async fn create_channel(&self, id: &str, name: &str, kind: &str) -> Result<bool> {
|
||||
pub async fn create_channel(&self, id: &str, name: &str, kind: &str, guild_id: Option<&str>) -> Result<bool> {
|
||||
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<Vec<ChannelRecord>> {
|
||||
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<String>, 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<String>, 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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ChannelListItem> = 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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -78,8 +78,15 @@ pub async fn dispatch<S: AsyncRead + AsyncWrite + Unpin>(
|
|||
.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)?;
|
||||
|
|
|
|||
|
|
@ -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<AtomicUsize>);
|
||||
|
||||
impl Drop for ConnGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(cfg: Arc<config::Config>, voice_member_tx: Option<VoiceMemberTx>) -> Result<()> {
|
||||
let private_mode = cfg.is_private();
|
||||
if private_mode {
|
||||
|
|
@ -28,8 +36,10 @@ pub async fn run(cfg: Arc<config::Config>, voice_member_tx: Option<VoiceMemberTx
|
|||
"VNOX Gateway — node: {} addr: {} bind: {}",
|
||||
cfg.node.name, cfg.node.address, cfg.gateway.bind
|
||||
);
|
||||
if let Some(limit) = cfg.gateway.max_connections {
|
||||
info!("max_connections configured: {limit} (not enforced yet)");
|
||||
let max_connections = cfg.gateway.max_connections;
|
||||
let conn_count = Arc::new(AtomicUsize::new(0));
|
||||
if let Some(limit) = max_connections {
|
||||
info!("max_connections configured: {limit}");
|
||||
}
|
||||
let storage = match cfg.storage.backend.as_deref().unwrap_or("sqlite") {
|
||||
"postgres" => {
|
||||
|
|
@ -114,13 +124,19 @@ pub async fn run(cfg: Arc<config::Config>, voice_member_tx: Option<VoiceMemberTx
|
|||
info!("listening on {}", cfg.gateway.bind);
|
||||
|
||||
if cfg.gateway.tls_enabled {
|
||||
run_tls(listener, cfg, state).await
|
||||
run_tls(listener, cfg, state, max_connections, conn_count).await
|
||||
} else {
|
||||
run_plain(listener, state).await
|
||||
run_plain(listener, state, max_connections, conn_count).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_tls(listener: TcpListener, cfg: Arc<config::Config>, state: State) -> Result<()> {
|
||||
async fn run_tls(
|
||||
listener: TcpListener,
|
||||
cfg: Arc<config::Config>,
|
||||
state: State,
|
||||
max_connections: Option<usize>,
|
||||
conn_count: Arc<AtomicUsize>,
|
||||
) -> 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<config::Config>, 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<config::Config>, state: State)
|
|||
}
|
||||
}
|
||||
|
||||
async fn run_plain(listener: TcpListener, state: State) -> Result<()> {
|
||||
async fn run_plain(
|
||||
listener: TcpListener,
|
||||
state: State,
|
||||
max_connections: Option<usize>,
|
||||
conn_count: Arc<AtomicUsize>,
|
||||
) -> 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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue