Initial commit: split VNOX monorepo into VNOX-Server

Unified server binary (vnox-serverd) combining:
- Gateway (TCP, auth, channels, sessions, SQLite)
- Voice-node (UDP relay, Opus, jitter buffer)

Standalone gateway (vnox-gateway) and voice-node (vnox-voice-node) preserved as sub-crates.
This commit is contained in:
loki5512344 2026-07-09 11:52:56 +02:00
commit e751e0bf5b
94 changed files with 9771 additions and 0 deletions

29
.cargo/config.toml Normal file
View file

@ -0,0 +1,29 @@
[env]
# audiopus_sys builds opus from source via CMake.
# CMake 4.x removed compatibility with cmake_minimum_required < 3.5.
# This flag tells CMake to accept the old policy version.
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
# Cap linker+rustc memory pressure so builds don't OOM-kill the host.
RUST_MIN_STACK = "8388608"
[build]
# Limit parallelism so GPUI's giant dep tree doesn't saturate all cores.
# 4 jobs is a safe-ish ceiling for a 15GB / 24-thread box.
jobs = 6
[profile.dev]
# Less debug info = less RAM per codegen unit, faster cold builds.
debug = 1
# More codegen units = smaller per-unit memory footprint.
codegen-units = 16
# Don't keep intermediate artifacts for every dep.
split-debuginfo = "unpacked"
[profile.dev.package."*"]
# Deps don't need debuginfo at all → big savings on big trees like GPUI.
debug = 0
codegen-units = 16
[profile.release]
debug = 0
codegen-units = 16

32
.github/.github/ISSUE_TEMPLATE/bug.md vendored Normal file
View file

@ -0,0 +1,32 @@
---
name: Bug report
about: Something is broken
labels: bug
---
## What happened
<!-- Describe the bug clearly -->
## Expected behavior
<!-- What should have happened -->
## Steps to reproduce
1.
2.
3.
## Environment
- OS:
- VNOX version:
- LNEx version:
- Install method: Docker / systemd / binary / built from source
## Logs
```
paste relevant log lines here (VNOX_LOG=debug)
```

View file

@ -0,0 +1,25 @@
---
name: Protocol change proposal
about: Propose a change to LNEx protocol
labels: protocol
---
## Summary
<!-- One paragraph: what changes and why -->
## Motivation
<!-- What problem does this solve? -->
## Proposed change
<!-- Be specific: packet format, new packet type, behavior change -->
## Backward compatibility
<!-- Is this a breaking change? How should old clients/servers behave? -->
## Alternatives considered
<!-- What else did you consider? -->

68
.github/.github/repo-metadata.md vendored Normal file
View file

@ -0,0 +1,68 @@
# Vnox Repository Metadata
## Description
Self-hosted realtime voice and chat platform. Decentralized, lightweight, and moddable. Built on LNEx, a custom protocol for low-latency federated communication. Not Discord. Not TeamSpeak. Not cloud.
## Topics / Tags
- communication
- voice-chat
- rust
- self-hosted
- decentralized
- real-time
- low-latency
- protocol
- federated
- lightweight
- moddable
- open-source
- p2p
- networking
- gateway
- voice-relay
- chat
- lnex
## Short Descriptions
### Primary (for GitHub repo description)
Self-hosted realtime voice and chat on a custom low-latency protocol. Decentralized, lightweight, and extensible.
### Secondary Options
- Decentralized alternative to Discord for self-hosted deployment
- Low-latency federated communication platform with custom LNEx protocol
- Open-source voice and chat server written in Rust
## Key Features
- Self-hosted deployment
- Decentralized architecture
- Real-time voice and text communication
- Low-latency protocol (LNEx)
- Lightweight footprint
- Extensible/moddable design
- Federation support (Phase 3 roadmap)
- Desktop client with egui UI
- SQLite/PostgreSQL backend support
## Technology Stack
- **Language**: Rust (99.6%)
- **Protocol**: LNEx (custom)
- **Audio**: Opus codec
- **Database**: SQLite (with PostgreSQL support planned)
- **UI**: egui (desktop client)
- **Transport**: TCP (gateway), UDP (voice relay)
## Current Status
- Phase 1: Implemented, not production-ready
- 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
- Federation: 🔄 Planned (Phase 3)
- Mobile client: 🔄 Planned (Phase 3)
⚠️ **Security Warning**: Traffic in v0.1.x is unencrypted plaintext. Do not use in production.
## License
- **Code**: GPL-3.0
- **Protocol**: CC0 (Public Domain)

47
.github/.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: CI
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Install system deps
run: sudo apt-get install -y libasound2-dev libopus-dev pkg-config
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: cargo fmt
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: cargo build
run: cargo build --all
- name: cargo test
run: cargo test --workspace

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
target/
dev/data/
*.db
.DS_Store

2545
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

35
Cargo.toml Normal file
View file

@ -0,0 +1,35 @@
[workspace]
resolver = "2"
members = [
"gateway",
"voice-node",
"serverd",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "GPL-3.0"
authors = ["VNOX Contributors"]
repository = "https://github.com/loki5512344/Vnox"
rust-version = "1.85"
[workspace.dependencies]
tokio = { version = "1.52", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ed25519-dalek = { version = "2", features = ["rand_core"] }
x25519-dalek = { version = "2", features = ["static_secrets"] }
chacha20poly1305 = "0.10"
argon2 = "0.5"
rand = "0.8"
opus = "0.3"
uuid = { version = "1", features = ["v4"] }
hex = "0.4"
hkdf = "0.13"
sha2 = "0.11"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
thiserror = "2"
toml = "1.1"

25
Dockerfile Normal file
View file

@ -0,0 +1,25 @@
# ─── Stage 1: Build ───────────────────────────────────────────────────────────
FROM rust:1.85-slim-bookworm AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends \
pkg-config libssl-dev libopus-dev cmake && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY . .
RUN cargo build --release --package vnox-gateway
# ─── Stage 2: Runtime ─────────────────────────────────────────────────────────
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/vnox-gateway /usr/local/bin/vnox-gateway
EXPOSE 7600/tcp
ENTRYPOINT ["vnox-gateway"]

21
Dockerfile.voice-node Normal file
View file

@ -0,0 +1,21 @@
# ─── Stage 1: Build ───────────────────────────────────────────────────────────
FROM rust:1.85-slim-bookworm AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends \
pkg-config libssl-dev libopus-dev cmake && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY . .
RUN cargo build --release --package vnox-voice-node
# ─── Stage 2: Runtime ─────────────────────────────────────────────────────────
FROM debian:bookworm-slim
COPY --from=builder /build/target/release/vnox-voice-node /usr/local/bin/vnox-voice-node
EXPOSE 7700/udp
ENTRYPOINT ["vnox-voice-node"]

82
dev/dev/README.md Normal file
View file

@ -0,0 +1,82 @@
# Local development configuration
This directory holds the default config for running VNOX on your machine.
## File: `config.toml`
```toml
[node]
name = "dev-node"
address = "127.0.0.1"
[gateway]
bind = "127.0.0.1:7600"
max_connections = 100
session_timeout = 600
[voice]
bind = "127.0.0.1:7700"
[storage]
data_dir = "./dev/data"
backend = "sqlite"
sqlite_path = "./dev/data/vnox.db"
```
### Sections
**`[node]`**
- `name` - shown to clients after connect (HELLO / session UI).
- `address` - public node address for future federation; local dev uses loopback.
**`[gateway]`**
- `bind` - TCP listen address for client connections. Default dev port: `7600`.
- `max_connections` - connection cap (not enforced in all code paths yet).
- `session_timeout` - session lifetime in seconds; sent to clients as `expires_at`.
**`[voice]`**
- `bind` - UDP listen address for voice relay. Default dev port: `7700`.
- Sent to clients as `voice_endpoint` when they join a channel.
**`[storage]`**
- `data_dir` - gateway data directory; stores SQLite DB and `server_identity.json`.
- `sqlite_path` - SQLite database file for chat history and user records.
## First run
```sh
mkdir -p dev/data
cargo run -p vnox-gateway -- --config dev/config.toml
cargo run -p vnox-voice-node -- --config dev/config.toml
cargo run -p vnox-client
```
On first gateway start, `dev/data/server_identity.json` is created automatically.
Back it up if you care about a stable server identity across reinstalls.
## E2E voice test
With gateway and voice-node running:
```sh
cargo run -p vnox-client --bin vnox-e2e-voice
```
This headless test connects two TCP clients, joins the `voice` channel, sends UDP packets, and verifies the voice-node relays between them. Exit code 0 means pass.
## Logs
Set log level via environment variable:
```sh
VNOX_LOG=debug cargo run -p vnox-gateway -- --config dev/config.toml
```
## Full reference
Production-oriented options: [docs/03-server/configuration.md](../docs/03-server/configuration.md).

25
dev/dev/config.toml Normal file
View file

@ -0,0 +1,25 @@
[node]
name = "dev-node"
address = "127.0.0.1"
[gateway]
bind = "127.0.0.1:7600"
max_connections = 100
session_timeout = 600
# Admin HTTP server (/health, /metrics, /version). Default 0.0.0.0:7601.
admin_bind = "127.0.0.1:7601"
# Per-session chat/DM rate limit (messages per second; 0 to disable).
message_rate_per_sec = 5
# Burst size for the token bucket.
message_rate_burst = 10
[voice]
bind = "127.0.0.1:7700"
[storage]
data_dir = "./dev/data"
backend = "sqlite"
sqlite_path = "./dev/data/vnox.db"
[server]
mode = "private"

24
docker-compose.yml Normal file
View file

@ -0,0 +1,24 @@
services:
gateway:
build:
context: .
dockerfile: Dockerfile
network_mode: "host"
volumes:
- ./dev/config.toml:/etc/vnox/config.toml:ro
- vnox-data:/var/lib/vnox
command: ["--config", "/etc/vnox/config.toml"]
restart: unless-stopped
voice-node:
build:
context: .
dockerfile: Dockerfile.voice-node
network_mode: "host"
depends_on:
- gateway
command: ["--config", "/etc/vnox/config.toml"]
restart: unless-stopped
volumes:
vnox-data:

40
gateway/Cargo.toml Normal file
View file

@ -0,0 +1,40 @@
[package]
name = "vnox-gateway"
description = "VNOX gateway — auth, channels, sessions, permissions"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[lib]
name = "vnox_gateway"
path = "src/lib.rs"
[[bin]]
name = "vnox-gateway"
path = "src/main.rs"
[dependencies]
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
ed25519-dalek.workspace = true
x25519-dalek.workspace = true
chacha20poly1305.workspace = true
hkdf.workspace = true
sha2.workspace = true
argon2.workspace = true
rand.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
thiserror.workspace = true
toml.workspace = true
uuid.workspace = true
hex.workspace = true
axum = { version = "0.8", features = ["ws"] }
tower = "0.5"
sqlx = { version = "0.9", features = ["sqlite", "runtime-tokio", "tls-rustls"] }
bitflags = "2"

View file

@ -0,0 +1,130 @@
//! Prometheus-compatible metrics counters.
//!
//! All counters are atomic and lock-free. The text format output follows the
//! Prometheus exposition format v0.0.4 so it can be scraped directly.
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use std::time::Instant;
use crate::admin::AdminState;
/// Container for all gateway metrics. Cheap to clone (Arc-backed).
#[derive(Debug, Default)]
pub struct Metrics {
pub messages_sent: AtomicU64,
pub dm_messages_sent: AtomicU64,
pub voice_packets_relayed: AtomicU64,
pub connections_total: AtomicU64,
pub auth_failures: AtomicU64,
pub rate_limited_events: AtomicU64,
pub errors_total: AtomicU64,
pub guilds_total: AtomicU64,
pub friends_requests_total: AtomicU64,
}
impl Metrics {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn inc(&self, field: &AtomicU64) {
field.fetch_add(1, Ordering::Relaxed);
}
/// Render metrics in Prometheus text exposition format.
pub fn render_prometheus(&self, s: &AdminState) -> String {
let uptime = s.started_at.elapsed().as_secs();
let sessions = s.sessions_count.load(Ordering::Relaxed);
let channels = s.channels_count.load(Ordering::Relaxed);
let mut out = String::with_capacity(2048);
out.push_str("# HELP vnox_uptime_seconds Gateway uptime in seconds.\n");
out.push_str("# TYPE vnox_uptime_seconds counter\n");
out.push_str(&format!("vnox_uptime_seconds {uptime}\n\n"));
out.push_str("# HELP vnox_sessions_active Currently active sessions.\n");
out.push_str("# TYPE vnox_sessions_active gauge\n");
out.push_str(&format!("vnox_sessions_active {sessions}\n\n"));
out.push_str("# HELP vnox_channels_active Currently active channels.\n");
out.push_str("# TYPE vnox_channels_active gauge\n");
out.push_str(&format!("vnox_channels_active {channels}\n\n"));
out.push_str("# HELP vnox_messages_sent_total Total chat messages sent.\n");
out.push_str("# TYPE vnox_messages_sent_total counter\n");
out.push_str(&format!(
"vnox_messages_sent_total {}\n\n",
self.messages_sent.load(Ordering::Relaxed)
));
out.push_str("# HELP vnox_dm_messages_sent_total Total DM messages sent.\n");
out.push_str("# TYPE vnox_dm_messages_sent_total counter\n");
out.push_str(&format!(
"vnox_dm_messages_sent_total {}\n\n",
self.dm_messages_sent.load(Ordering::Relaxed)
));
out.push_str("# HELP vnox_voice_packets_relayed_total Total UDP voice packets relayed.\n");
out.push_str("# TYPE vnox_voice_packets_relayed_total counter\n");
out.push_str(&format!(
"vnox_voice_packets_relayed_total {}\n\n",
self.voice_packets_relayed.load(Ordering::Relaxed)
));
out.push_str("# HELP vnox_connections_total Total TCP connections accepted.\n");
out.push_str("# TYPE vnox_connections_total counter\n");
out.push_str(&format!(
"vnox_connections_total {}\n\n",
self.connections_total.load(Ordering::Relaxed)
));
out.push_str("# HELP vnox_auth_failures_total Total authentication failures.\n");
out.push_str("# TYPE vnox_auth_failures_total counter\n");
out.push_str(&format!(
"vnox_auth_failures_total {}\n\n",
self.auth_failures.load(Ordering::Relaxed)
));
out.push_str("# HELP vnox_rate_limited_events_total Total rate-limited requests.\n");
out.push_str("# TYPE vnox_rate_limited_events_total counter\n");
out.push_str(&format!(
"vnox_rate_limited_events_total {}\n\n",
self.rate_limited_events.load(Ordering::Relaxed)
));
out.push_str("# HELP vnox_errors_total Total error packets sent.\n");
out.push_str("# TYPE vnox_errors_total counter\n");
out.push_str(&format!(
"vnox_errors_total {}\n\n",
self.errors_total.load(Ordering::Relaxed)
));
out.push_str("# HELP vnox_guilds_total Total guilds tracked.\n");
out.push_str("# TYPE vnox_guilds_total gauge\n");
out.push_str(&format!(
"vnox_guilds_total {}\n\n",
self.guilds_total.load(Ordering::Relaxed)
));
out.push_str("# HELP vnox_friends_requests_total Total friend requests sent.\n");
out.push_str("# TYPE vnox_friends_requests_total counter\n");
out.push_str(&format!(
"vnox_friends_requests_total {}\n\n",
self.friends_requests_total.load(Ordering::Relaxed)
));
out
}
}
/// Helper to bump a metric from anywhere that holds an Arc<Metrics>.
#[allow(dead_code)]
pub fn bump(m: &Arc<Metrics>, field: fn(&Metrics) -> &AtomicU64) {
m.inc(field(m));
}
#[allow(dead_code)]
fn _silence_unused_helper(_start: Instant) {}

77
gateway/src/admin/mod.rs Normal file
View file

@ -0,0 +1,77 @@
//! HTTP admin server — exposes `/health` and `/metrics` endpoints.
//!
//! Runs on a separate TCP port (default 7601) so it does not interfere
//! with the LNEx control plane on 7600. All endpoints are unauthenticated
//! and intended for orchestrators (k8s liveness probes, Prometheus scrapers).
use std::sync::Arc;
use std::time::Instant;
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
use tokio::net::TcpListener;
use tracing::{error, info};
use crate::admin::metrics::Metrics;
pub mod metrics;
#[derive(Clone)]
pub struct AdminState {
pub started_at: Instant,
pub node_name: String,
pub node_address: String,
pub private_mode: bool,
pub metrics: Arc<Metrics>,
pub sessions_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
pub channels_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
pub async fn run(bind: String, state: AdminState) {
let app = Router::new()
.route("/health", get(health))
.route("/metrics", get(metrics))
.route("/version", get(version))
.with_state(state);
let listener = match TcpListener::bind(&bind).await {
Ok(l) => l,
Err(e) => {
error!("admin server failed to bind {bind}: {e}");
return;
}
};
info!("admin HTTP listening on {bind}");
if let Err(e) = axum::serve(listener, app).await {
error!("admin server: {e}");
}
}
async fn health(State(s): State<AdminState>) -> impl IntoResponse {
let uptime = s.started_at.elapsed().as_secs();
let body = serde_json::json!({
"status": "ok",
"uptime_seconds": uptime,
"node": s.node_name,
"address": s.node_address,
"private_mode": s.private_mode,
});
(StatusCode::OK, axum::Json(body))
}
async fn version(State(_s): State<AdminState>) -> impl IntoResponse {
let body = serde_json::json!({
"name": "vnox-gateway",
"version": env!("CARGO_PKG_VERSION"),
"lnex_version": "1",
});
(StatusCode::OK, axum::Json(body))
}
async fn metrics(State(s): State<AdminState>) -> impl IntoResponse {
let body = s.metrics.render_prometheus(&s);
(
StatusCode::OK,
[("content-type", "text/plain; version=0.0.4")],
body,
)
}

View file

@ -0,0 +1 @@
pub mod server_identity;

View file

@ -0,0 +1,58 @@
use anyhow::Result;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredIdentity {
pubkey_hex: String,
privkey_hex: String,
}
/// Gateway Ed25519 identity — loaded from disk or generated on first start.
pub struct ServerIdentity {
signing_key: SigningKey,
}
impl ServerIdentity {
pub fn pubkey_hex(&self) -> String {
hex::encode(self.signing_key.verifying_key().to_bytes())
}
pub fn load_or_generate(data_dir: &Path) -> Result<Self> {
let path = data_dir.join("server_identity.json");
if path.exists() {
let text = std::fs::read_to_string(&path)?;
let stored: StoredIdentity = serde_json::from_str(&text)?;
let signing_key = SigningKey::from_bytes(
hex::decode(&stored.privkey_hex)?
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("invalid server private key length"))?,
);
let computed_pubkey = hex::encode(signing_key.verifying_key().to_bytes());
if stored.pubkey_hex != computed_pubkey {
return Err(anyhow::anyhow!(
"server_identity.json pubkey does not match private key"
));
}
return Ok(Self { signing_key });
}
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
let pubkey_hex = hex::encode(verifying_key.to_bytes());
let stored = StoredIdentity {
pubkey_hex,
privkey_hex: hex::encode(signing_key.to_bytes()),
};
std::fs::create_dir_all(data_dir)?;
std::fs::write(&path, serde_json::to_string_pretty(&stored)?)?;
Ok(Self { signing_key })
}
}

View file

@ -0,0 +1,45 @@
use anyhow::Result;
use ed25519_dalek::{Signature, VerifyingKey};
/// Verify Ed25519 signature from AUTH packet.
pub fn verify_auth(challenge: &[u8; 32], pubkey: &[u8; 32], signature: &[u8; 64]) -> Result<()> {
let key = VerifyingKey::from_bytes(pubkey).map_err(|_| anyhow::anyhow!("invalid pubkey"))?;
let sig = Signature::from_bytes(signature);
key.verify_strict(challenge, &sig)
.map_err(|_| anyhow::anyhow!("invalid signature"))
}
/// Random 32-byte challenge nonce for HELLO.
pub fn new_challenge() -> [u8; 32] {
use rand::RngCore;
let mut n = [0u8; 32];
rand::thread_rng().fill_bytes(&mut n);
n
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
use rand::rngs::OsRng;
#[test]
fn verify_auth_accepts_valid_signature() {
let sk = SigningKey::generate(&mut OsRng);
let vk = sk.verifying_key();
let challenge = [7u8; 32];
let sig = sk.sign(&challenge);
verify_auth(&challenge, vk.as_bytes(), &sig.to_bytes()).unwrap();
}
#[test]
fn verify_auth_rejects_tampered_challenge() {
let sk = SigningKey::generate(&mut OsRng);
let vk = sk.verifying_key();
let challenge = [7u8; 32];
let sig = sk.sign(&challenge);
let mut other = challenge;
other[0] ^= 1;
assert!(verify_auth(&other, vk.as_bytes(), &sig.to_bytes()).is_err());
}
}

View file

@ -0,0 +1,55 @@
pub mod ops;
pub use ops::{create, delete, get_channel, join, leave, list, members};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, PartialEq)]
pub enum ChannelKind {
Text,
Voice,
}
impl ChannelKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Text => "text",
Self::Voice => "voice",
}
}
}
#[derive(Debug, Clone)]
pub struct Channel {
pub id: String,
pub name: String,
pub kind: ChannelKind,
pub members: HashSet<String>,
}
pub type ChannelStore = Arc<RwLock<HashMap<String, Channel>>>;
pub fn new_store() -> ChannelStore {
let mut m = HashMap::new();
m.insert(
"general".into(),
Channel {
id: "general".into(),
name: "general".into(),
kind: ChannelKind::Text,
members: HashSet::new(),
},
);
m.insert(
"voice".into(),
Channel {
id: "voice".into(),
name: "voice".into(),
kind: ChannelKind::Voice,
members: HashSet::new(),
},
);
Arc::new(RwLock::new(m))
}

View file

@ -0,0 +1,64 @@
use crate::domain::channels::{Channel, ChannelKind, ChannelStore};
pub async fn join(store: &ChannelStore, channel_id: &str, session_id: &str) -> bool {
let mut l = store.write().await;
if let Some(ch) = l.get_mut(channel_id) {
ch.members.insert(session_id.into());
true
} else {
false
}
}
pub async fn leave(store: &ChannelStore, channel_id: &str, session_id: &str) {
if let Some(ch) = store.write().await.get_mut(channel_id) {
ch.members.remove(session_id);
}
}
pub async fn members(store: &ChannelStore, channel_id: &str) -> Vec<String> {
store
.read()
.await
.get(channel_id)
.map(|ch| ch.members.iter().cloned().collect())
.unwrap_or_default()
}
pub async fn get_channel(store: &ChannelStore, channel_id: &str) -> Option<Channel> {
store.read().await.get(channel_id).cloned()
}
/// Create a new channel in the store. Returns `false` if a channel with the
/// same id already exists.
pub async fn create(
store: &ChannelStore,
channel_id: &str,
channel_name: &str,
kind: ChannelKind,
) -> bool {
let mut l = store.write().await;
if l.contains_key(channel_id) {
return false;
}
l.insert(
channel_id.to_string(),
Channel {
id: channel_id.to_string(),
name: channel_name.to_string(),
kind,
members: std::collections::HashSet::new(),
},
);
true
}
/// Remove a channel from the store. Returns `true` if it existed.
pub async fn delete(store: &ChannelStore, channel_id: &str) -> bool {
store.write().await.remove(channel_id).is_some()
}
/// List all channels in the store.
pub async fn list(store: &ChannelStore) -> Vec<Channel> {
store.read().await.values().cloned().collect()
}

View file

@ -0,0 +1,118 @@
use anyhow::Result;
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
pub struct Config {
pub node: NodeConfig,
pub gateway: GatewayConfig,
pub voice: VoiceConfig,
pub storage: StorageConfig,
#[serde(default)]
pub server: ServerConfig,
pub federation: Option<FederationConfig>,
}
impl Config {
/// Returns `true` when private mode is active — federation disabled,
/// server is isolated, client shows a "PRIVATE" badge.
pub fn is_private(&self) -> bool {
match self.server.mode {
Some(ref m) => m.is_private(),
None => self
.federation
.as_ref()
.map(|f| f.is_private())
.unwrap_or(true),
}
}
}
#[derive(Debug, Deserialize, Default)]
pub struct ServerConfig {
/// Explicit server mode: "private" or "federated".
/// Overrides `[federation] enabled` when set.
pub mode: Option<ServerMode>,
}
/// Server isolation mode.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ServerMode {
/// Fully isolated — no federation, no node discovery.
Private,
/// Can discover, peer, and sync with other VNOX nodes.
Federated,
}
impl ServerMode {
pub fn is_private(&self) -> bool {
matches!(self, ServerMode::Private)
}
}
#[derive(Debug, Deserialize)]
pub struct FederationConfig {
pub enabled: bool,
/// If true, the server runs in private mode (federation disabled, client shows a badge).
/// Defaults to the inverse of `enabled` when not set.
pub private_mode: Option<bool>,
}
impl FederationConfig {
/// Returns `true` when private mode is active — federation is disabled and the
/// server does not exchange any data with other nodes.
pub fn is_private(&self) -> bool {
self.private_mode.unwrap_or(!self.enabled)
}
}
#[derive(Debug, Deserialize)]
pub struct NodeConfig {
pub name: String,
pub address: String,
}
#[derive(Debug, Deserialize)]
pub struct GatewayConfig {
pub bind: String,
pub max_connections: Option<usize>,
pub session_timeout: Option<u64>,
/// Bind address for the admin HTTP server (/health, /metrics).
/// Defaults to "0.0.0.0:7601" when not specified.
#[serde(default)]
pub admin_bind: Option<String>,
/// Per-session chat/DM message rate limit (messages per second).
/// Defaults to 5.0 when not specified. Set to 0 to disable.
#[serde(default)]
pub message_rate_per_sec: Option<f32>,
/// Burst size for the token bucket (messages allowed in one tick).
/// Defaults to 10.
#[serde(default)]
pub message_rate_burst: Option<u32>,
}
#[derive(Debug, Deserialize)]
pub struct VoiceConfig {
pub bind: String,
}
#[derive(Debug, Deserialize)]
pub struct StorageConfig {
pub data_dir: PathBuf,
pub backend: Option<String>,
pub sqlite_path: Option<PathBuf>,
pub postgres_url: Option<String>,
}
pub fn load() -> Result<Config> {
let path = std::env::args()
.skip_while(|a| a != "--config")
.nth(1)
.unwrap_or_else(|| "/etc/vnox/config.toml".into());
let text = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("cannot read config {path}: {e}"))?;
toml::from_str(&text).map_err(|e| anyhow::anyhow!("invalid config: {e}"))
}

View file

@ -0,0 +1,7 @@
pub mod auth;
pub mod channels;
pub mod config;
pub mod permissions;
pub mod rate_limit;
pub mod session;
pub mod storage;

View file

@ -0,0 +1,33 @@
use bitflags::bitflags;
bitflags! {
#[derive(Debug, Clone, Copy)]
pub struct Permissions: u64 {
const CREATE_INVITE = 1 << 0;
const KICK_MEMBERS = 1 << 1;
const BAN_MEMBERS = 1 << 2;
const MANAGE_CHANNELS = 1 << 3;
const MANAGE_ROLES = 1 << 4;
const MANAGE_GUILD = 1 << 5;
const MESSAGE_SEND = 1 << 6;
const MESSAGE_MANAGE = 1 << 7;
const VOICE_SPEAK = 1 << 8;
const VOICE_MUTE = 1 << 9;
const VOICE_DEAFEN = 1 << 10;
const ADMINISTRATOR = 1 << 63;
}
}
impl Permissions {
pub fn has(&self, required: Permissions) -> bool {
self.contains(required) || self.contains(Permissions::ADMINISTRATOR)
}
pub fn from_role_perms(perms: &[u64]) -> Permissions {
let mut combined = Permissions::empty();
for &p in perms {
combined |= Permissions::from_bits_truncate(p);
}
combined
}
}

View file

@ -0,0 +1,117 @@
//! Per-session token-bucket rate limiter.
//!
//! Each authenticated session gets its own bucket. The bucket refills at
//! `rate_per_sec` tokens per second, up to a maximum of `burst` tokens.
//! A message consumes 1 token; if the bucket is empty, the message is
//! rejected with `ErrorCode::RateLimited`.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Instant;
use crate::domain::session::SessionStore;
pub struct RateLimiter {
inner: Mutex<HashMap<String, Bucket>>,
rate_per_sec: f32,
burst: u32,
}
struct Bucket {
tokens: f32,
last_refill: Instant,
}
impl RateLimiter {
pub fn new(rate_per_sec: f32, burst: u32) -> Self {
Self {
inner: Mutex::new(HashMap::new()),
rate_per_sec,
burst,
}
}
/// Try to consume one token. Returns `true` if allowed, `false` if rate-limited.
/// Lazily creates a bucket for new sessions.
pub fn try_consume(&self, session_id: &str) -> bool {
if self.rate_per_sec <= 0.0 {
return true;
}
let mut guard = self.inner.lock().unwrap();
let now = Instant::now();
let bucket = guard
.entry(session_id.to_string())
.or_insert_with(|| Bucket {
tokens: self.burst as f32,
last_refill: now,
});
// Refill
let elapsed = now.duration_since(bucket.last_refill).as_secs_f32();
bucket.tokens = (bucket.tokens + elapsed * self.rate_per_sec).min(self.burst as f32);
bucket.last_refill = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
true
} else {
false
}
}
/// Remove a session's bucket on disconnect.
pub fn remove(&self, session_id: &str) {
let mut guard = self.inner.lock().unwrap();
guard.remove(session_id);
}
/// Periodically prune buckets for sessions that no longer exist.
/// Returns the number of buckets pruned.
#[allow(dead_code)]
pub async fn prune_dead(&self, sessions: &SessionStore) -> usize {
let live: Vec<String> = sessions.read().await.keys().cloned().collect();
let live_set: std::collections::HashSet<String> = live.into_iter().collect();
let mut guard = self.inner.lock().unwrap();
let before = guard.len();
guard.retain(|sid, _| live_set.contains(sid));
before - guard.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_within_burst() {
let r = RateLimiter::new(10.0, 3);
assert!(r.try_consume("s1"));
assert!(r.try_consume("s1"));
assert!(r.try_consume("s1"));
assert!(!r.try_consume("s1")); // bucket empty
}
#[test]
fn independent_buckets_per_session() {
let r = RateLimiter::new(10.0, 1);
assert!(r.try_consume("s1"));
assert!(!r.try_consume("s1"));
assert!(r.try_consume("s2"));
}
#[test]
fn disabled_when_rate_zero() {
let r = RateLimiter::new(0.0, 1);
for _ in 0..100 {
assert!(r.try_consume("s1"));
}
}
#[test]
fn remove_clears_bucket() {
let r = RateLimiter::new(0.1, 1);
assert!(r.try_consume("s1"));
assert!(!r.try_consume("s1"));
r.remove("s1");
// After removal, a fresh bucket is created with full burst.
assert!(r.try_consume("s1"));
}
}

View file

@ -0,0 +1,48 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct Session {
pub session_id: String,
pub token: String,
pub user_id: String,
pub nickname: String,
pub channel_id: Option<String>,
}
pub type SessionStore = Arc<RwLock<HashMap<String, Session>>>;
pub fn new_store() -> SessionStore {
Arc::new(RwLock::new(HashMap::new()))
}
pub async fn create(store: &SessionStore, user_id: String, nickname: String) -> Session {
let s = Session {
session_id: Uuid::new_v4().to_string(),
token: Uuid::new_v4().to_string(),
user_id,
nickname,
channel_id: None,
};
store.write().await.insert(s.session_id.clone(), s.clone());
s
}
pub async fn get(store: &SessionStore, id: &str) -> Option<Session> {
store.read().await.get(id).cloned()
}
pub async fn remove(store: &SessionStore, id: &str) {
store.write().await.remove(id);
}
pub async fn get_session_id_by_user_id(store: &SessionStore, user_id: &str) -> Option<String> {
store
.read()
.await
.iter()
.find(|(_, s)| s.user_id == user_id)
.map(|(id, _)| id.clone())
}

View file

@ -0,0 +1,38 @@
use anyhow::Result;
impl super::Storage {
pub async fn increment_dm_unread(&self, dm_id: &str, recipient_id: &str) -> Result<()> {
let (u1, _u2) = sqlx::query_as::<_, (String, String)>(
"SELECT user1_id,user2_id FROM direct_messages WHERE id=?",
)
.bind(dm_id)
.fetch_one(&self.pool)
.await?;
if recipient_id == u1 {
sqlx::query("UPDATE direct_messages SET unread_count_2=unread_count_2+1 WHERE id=?")
.bind(dm_id)
.execute(&self.pool)
.await?;
} else {
sqlx::query("UPDATE direct_messages SET unread_count_1=unread_count_1+1 WHERE id=?")
.bind(dm_id)
.execute(&self.pool)
.await?;
}
Ok(())
}
pub async fn reset_dm_unread(&self, dm_id: &str, user_id: &str) -> Result<()> {
sqlx::query(
"UPDATE direct_messages SET \
unread_count_1 = CASE WHEN user1_id=? THEN 0 ELSE unread_count_1 END, \
unread_count_2 = CASE WHEN user2_id=? THEN 0 ELSE unread_count_2 END WHERE id=?",
)
.bind(user_id)
.bind(user_id)
.bind(dm_id)
.execute(&self.pool)
.await?;
Ok(())
}
}

View file

@ -0,0 +1,127 @@
use anyhow::Result;
use crate::proto::DmMessagePayload;
#[derive(sqlx::FromRow)]
struct DmMsgRow {
dm_id: String,
sender_id: String,
body: String,
created_at: i64,
}
impl super::Storage {
pub async fn find_or_create_dm(&self, user1: &str, user2: &str) -> Result<(String, i64)> {
let (u1, u2) = if user1 < user2 {
(user1, user2)
} else {
(user2, user1)
};
let existing = sqlx::query_as::<_, (String, i64, i64)>(
"SELECT id,unread_count_1,unread_count_2 FROM direct_messages WHERE user1_id=? AND user2_id=?",
).bind(u1).bind(u2).fetch_optional(&self.pool).await?;
if let Some((id, uc1, uc2)) = existing {
return Ok((id, if u1 == user1 { uc1 } else { uc2 }));
}
let dm_id = format!("dm_{}_{}", u1, u2);
let now = super::now_ms();
sqlx::query(
"INSERT INTO direct_messages (id,user1_id,user2_id,created_at,unread_count_1,unread_count_2) VALUES (?,?,?,?,0,0)",
).bind(&dm_id).bind(u1).bind(u2).bind(now).execute(&self.pool).await?;
Ok((dm_id, 0))
}
pub async fn save_dm_message(
&self,
dm_id: &str,
sender_id: &str,
body: &str,
) -> Result<DmMessagePayload> {
let msg_id = uuid::Uuid::new_v4().to_string();
let ts = super::now_ms();
sqlx::query(
"INSERT INTO dm_messages (id,dm_id,sender_id,body,created_at) VALUES (?,?,?,?,?)",
)
.bind(&msg_id)
.bind(dm_id)
.bind(sender_id)
.bind(body)
.bind(ts)
.execute(&self.pool)
.await?;
sqlx::query("UPDATE direct_messages SET last_message_at=? WHERE id=?")
.bind(ts)
.bind(dm_id)
.execute(&self.pool)
.await?;
Ok(DmMessagePayload {
dm_id: dm_id.to_string(),
sender_id: sender_id.to_string(),
content: body.to_string(),
timestamp: ts,
})
}
pub async fn get_dm_messages(
&self,
dm_id: &str,
limit: i64,
search_query: Option<&str>,
before_timestamp: Option<i64>,
) -> Result<Vec<DmMessagePayload>> {
let mut sql = String::from(
"SELECT dm_id,sender_id,body,created_at FROM \
(SELECT * FROM dm_messages WHERE dm_id=? ",
);
if search_query.is_some() {
sql.push_str("AND body LIKE '%' || ? || '%' ");
}
if before_timestamp.is_some() {
sql.push_str("AND created_at < ? ");
}
sql.push_str("ORDER BY created_at DESC LIMIT ?) ORDER BY created_at ASC");
let mut q = sqlx::query_as::<_, DmMsgRow>(sqlx::AssertSqlSafe(sql.as_str()));
q = q.bind(dm_id);
if let Some(sq) = search_query {
q = q.bind(sq);
}
if let Some(bt) = before_timestamp {
q = q.bind(bt);
}
q = q.bind(limit);
let rows = q.fetch_all(&self.pool).await?;
Ok(rows
.into_iter()
.map(|r| DmMessagePayload {
dm_id: r.dm_id,
sender_id: r.sender_id,
content: r.body,
timestamp: r.created_at,
})
.collect())
}
pub async fn get_dm_user_id(&self, dm_id: &str, my_id: &str) -> Result<Option<String>> {
Ok(sqlx::query_as::<_, (String, String)>(
"SELECT user1_id,user2_id FROM direct_messages WHERE id=?",
)
.bind(dm_id)
.fetch_optional(&self.pool)
.await?
.map(|(u1, u2)| if u1 == my_id { u2 } else { u1 }))
}
pub async fn get_dm_nickname(&self, user_id: &str) -> Result<Option<String>> {
Ok(
sqlx::query_as::<_, (String,)>("SELECT nickname FROM users WHERE pubkey=?")
.bind(user_id)
.fetch_optional(&self.pool)
.await?
.map(|(n,)| n),
)
}
pub async fn get_nickname(&self, user_id: &str) -> Result<Option<String>> {
self.get_dm_nickname(user_id).await
}
}

View file

@ -0,0 +1,30 @@
use anyhow::Result;
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct AuditLogRow {
pub id: String,
pub guild_id: String,
pub actor_id: String,
pub action: String,
#[sqlx(default)]
pub target_id: Option<String>,
#[sqlx(default)]
pub target_type: Option<String>,
#[sqlx(default)]
pub reason: Option<String>,
pub created_at: i64,
}
impl super::super::Storage {
/// Fetch the last `limit` audit log entries for a guild, newest first.
pub async fn get_audit_log(&self, guild_id: &str, limit: i64) -> Result<Vec<AuditLogRow>> {
Ok(sqlx::query_as::<_, AuditLogRow>(
"SELECT id, guild_id, actor_id, action, target_id, target_type, reason, created_at \
FROM audit_logs WHERE guild_id=? ORDER BY created_at DESC LIMIT ?",
)
.bind(guild_id)
.bind(limit)
.fetch_all(&self.pool)
.await?)
}
}

View file

@ -0,0 +1,210 @@
pub mod audit;
pub mod roles;
use anyhow::Result;
#[allow(unused_imports)]
pub use audit::AuditLogRow;
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct GuildRow {
pub id: String,
pub owner_id: String,
pub name: String,
pub created_at: i64,
pub member_count: i64,
}
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct GuildMemberRow {
pub user_id: String,
pub nickname: String,
pub joined_at: i64,
/// Highest role color (or "#ffffff" if none).
#[sqlx(default)]
pub role_color: String,
/// Highest role name (or "member" if none).
#[sqlx(default)]
pub role_name: String,
}
#[derive(sqlx::FromRow, Debug)]
pub struct InviteRow {
pub id: String,
pub guild_id: String,
#[sqlx(default)]
pub guild_name: String,
pub creator_id: String,
pub code: String,
pub max_uses: Option<i64>,
pub uses: i64,
pub expires_at: Option<i64>,
pub created_at: i64,
}
impl super::Storage {
pub async fn create_guild(&self, owner_id: &str, name: &str) -> Result<String> {
let id = uuid::Uuid::new_v4().to_string();
let now = super::now_ms();
sqlx::query("INSERT INTO guilds (id,owner_id,name,created_at) VALUES (?,?,?,?)")
.bind(&id)
.bind(owner_id)
.bind(name)
.bind(now)
.execute(&self.pool)
.await?;
sqlx::query(
"INSERT OR IGNORE INTO guild_members (guild_id,user_id,joined_at) VALUES (?,?,?)",
)
.bind(&id)
.bind(owner_id)
.bind(now)
.execute(&self.pool)
.await?;
let role_id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO roles (id,guild_id,name,permissions,position,created_at) VALUES (?,?,?,?,0,?)")
.bind(&role_id).bind(&id).bind("@everyone").bind(u64::MAX as i64).bind(now)
.execute(&self.pool).await?;
sqlx::query("INSERT OR IGNORE INTO member_roles (guild_id,user_id,role_id) VALUES (?,?,?)")
.bind(&id)
.bind(owner_id)
.bind(&role_id)
.execute(&self.pool)
.await?;
Ok(id)
}
pub async fn get_guild(&self, guild_id: &str) -> Result<Option<GuildRow>> {
Ok(sqlx::query_as::<_, GuildRow>(
"SELECT g.id,g.owner_id,g.name,g.created_at, \
(SELECT COUNT(*) FROM guild_members WHERE guild_id=g.id) as member_count \
FROM guilds g WHERE g.id=?",
)
.bind(guild_id)
.fetch_optional(&self.pool)
.await?)
}
pub async fn list_user_guilds(&self, user_id: &str) -> Result<Vec<GuildRow>> {
Ok(sqlx::query_as::<_, GuildRow>(
"SELECT g.id,g.owner_id,g.name,g.created_at, \
(SELECT COUNT(*) FROM guild_members WHERE guild_id=g.id) as member_count \
FROM guilds g JOIN guild_members gm ON g.id=gm.guild_id \
WHERE gm.user_id=? ORDER BY g.name",
)
.bind(user_id)
.fetch_all(&self.pool)
.await?)
}
pub async fn delete_guild(&self, guild_id: &str) -> Result<()> {
sqlx::query("DELETE FROM guild_members WHERE guild_id=?")
.bind(guild_id)
.execute(&self.pool)
.await?;
sqlx::query("DELETE FROM roles WHERE guild_id=?")
.bind(guild_id)
.execute(&self.pool)
.await?;
sqlx::query("DELETE FROM invites WHERE guild_id=?")
.bind(guild_id)
.execute(&self.pool)
.await?;
sqlx::query("DELETE FROM guilds WHERE id=?")
.bind(guild_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn add_guild_member(&self, guild_id: &str, user_id: &str) -> Result<()> {
sqlx::query(
"INSERT OR IGNORE INTO guild_members (guild_id,user_id,joined_at) VALUES (?,?,?)",
)
.bind(guild_id)
.bind(user_id)
.bind(super::now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn remove_guild_member(&self, guild_id: &str, user_id: &str) -> Result<()> {
sqlx::query("DELETE FROM guild_members WHERE guild_id=? AND user_id=?")
.bind(guild_id)
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// List all members of a guild with their highest role (by position).
pub async fn list_guild_members(&self, guild_id: &str) -> Result<Vec<GuildMemberRow>> {
Ok(sqlx::query_as::<_, GuildMemberRow>(
"SELECT gm.user_id, COALESCE(u.nickname, gm.user_id) as nickname, gm.joined_at, \
COALESCE((SELECT r.color FROM roles r \
JOIN member_roles mr ON r.id=mr.role_id \
WHERE mr.guild_id=gm.guild_id AND mr.user_id=gm.user_id \
ORDER BY r.position DESC LIMIT 1), '#ffffff') as role_color, \
COALESCE((SELECT r.name FROM roles r \
JOIN member_roles mr ON r.id=mr.role_id \
WHERE mr.guild_id=gm.guild_id AND mr.user_id=gm.user_id \
ORDER BY r.position DESC LIMIT 1), 'member') as role_name \
FROM guild_members gm LEFT JOIN users u ON gm.user_id=u.pubkey \
WHERE gm.guild_id=? ORDER BY gm.joined_at ASC",
)
.bind(guild_id)
.fetch_all(&self.pool)
.await?)
}
/// Assign a role to a user in a guild (idempotent).
pub async fn assign_role(&self, guild_id: &str, user_id: &str, role_id: &str) -> Result<()> {
sqlx::query("INSERT OR IGNORE INTO member_roles (guild_id,user_id,role_id) VALUES (?,?,?)")
.bind(guild_id)
.bind(user_id)
.bind(role_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Remove a role from a user in a guild.
pub async fn remove_role_from_user(
&self,
guild_id: &str,
user_id: &str,
role_id: &str,
) -> Result<()> {
sqlx::query("DELETE FROM member_roles WHERE guild_id=? AND user_id=? AND role_id=?")
.bind(guild_id)
.bind(user_id)
.bind(role_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// List all roles defined in a guild.
pub async fn list_guild_roles(&self, guild_id: &str) -> Result<Vec<RoleFullRow>> {
Ok(sqlx::query_as::<_, RoleFullRow>(
"SELECT id, guild_id, name, color, permissions, position, created_at \
FROM roles WHERE guild_id=? ORDER BY position DESC",
)
.bind(guild_id)
.fetch_all(&self.pool)
.await?)
}
}
#[allow(dead_code)]
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct RoleFullRow {
pub id: String,
pub guild_id: String,
pub name: String,
pub color: String,
pub permissions: i64,
pub position: i32,
pub created_at: i64,
}

View file

@ -0,0 +1,117 @@
use anyhow::Result;
use super::InviteRow;
#[derive(sqlx::FromRow, Debug)]
pub struct RoleRow {
pub color: String,
pub position: i32,
}
impl super::super::Storage {
pub async fn create_role(
&self,
guild_id: &str,
name: &str,
color: &str,
permissions: u64,
position: i32,
) -> Result<String> {
let id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO roles (id,guild_id,name,color,permissions,position,created_at) VALUES (?,?,?,?,?,?,?)")
.bind(&id).bind(guild_id).bind(name).bind(color).bind(permissions as i64).bind(position).bind(super::super::now_ms())
.execute(&self.pool).await?;
Ok(id)
}
pub async fn delete_role(&self, role_id: &str) -> Result<()> {
sqlx::query("DELETE FROM roles WHERE id=?")
.bind(role_id)
.execute(&self.pool)
.await?;
sqlx::query("DELETE FROM member_roles WHERE role_id=?")
.bind(role_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_user_roles(&self, guild_id: &str, user_id: &str) -> Result<Vec<RoleRow>> {
Ok(sqlx::query_as::<_, RoleRow>(
"SELECT r.id,r.guild_id,r.name,r.color,r.permissions,r.position \
FROM roles r JOIN member_roles mr ON r.id=mr.role_id \
WHERE mr.guild_id=? AND mr.user_id=? ORDER BY r.position DESC",
)
.bind(guild_id)
.bind(user_id)
.fetch_all(&self.pool)
.await?)
}
pub async fn get_user_role_perms(&self, guild_id: &str, user_id: &str) -> Result<Vec<u64>> {
#[derive(sqlx::FromRow)]
struct P {
permissions: i64,
}
let rows: Vec<P> = sqlx::query_as::<_, P>(
"SELECT r.permissions FROM roles r \
JOIN member_roles mr ON r.id=mr.role_id \
WHERE mr.guild_id=? AND mr.user_id=?",
)
.bind(guild_id)
.bind(user_id)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|r| r.permissions as u64).collect())
}
pub async fn create_invite(
&self,
guild_id: &str,
creator_id: &str,
max_uses: Option<i64>,
expires_in_s: Option<i64>,
) -> Result<InviteRow> {
let id = uuid::Uuid::new_v4().to_string();
let code = super::super::generate_invite_code();
let now = super::super::now_ms();
let expires_at = expires_in_s.map(|s| now + s * 1000);
sqlx::query("INSERT INTO invites (id,guild_id,creator_id,code,max_uses,expires_at,created_at) VALUES (?,?,?,?,?,?,?)")
.bind(&id).bind(guild_id).bind(creator_id).bind(&code).bind(max_uses).bind(expires_at).bind(now)
.execute(&self.pool).await?;
Ok(InviteRow {
id,
guild_id: guild_id.into(),
guild_name: String::new(),
creator_id: creator_id.into(),
code,
max_uses,
uses: 0,
expires_at,
created_at: now,
})
}
pub async fn get_invite_by_code(&self, code: &str) -> Result<Option<InviteRow>> {
Ok(sqlx::query_as::<_, InviteRow>(
"SELECT i.id,i.guild_id,i.creator_id,i.code,i.max_uses,i.uses,i.expires_at,i.created_at, \
g.name as guild_name FROM invites i JOIN guilds g ON i.guild_id=g.id WHERE i.code=?"
).bind(code).fetch_optional(&self.pool).await?)
}
pub async fn use_invite(&self, invite_id: &str) -> Result<()> {
sqlx::query("UPDATE invites SET uses=uses+1 WHERE id=?")
.bind(invite_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn delete_invite(&self, invite_id: &str) -> Result<()> {
sqlx::query("DELETE FROM invites WHERE id=?")
.bind(invite_id)
.execute(&self.pool)
.await?;
Ok(())
}
}

View file

@ -0,0 +1,149 @@
use anyhow::Result;
use crate::proto::ChatMessagePayload;
#[derive(sqlx::FromRow)]
struct MsgRow {
id: String,
channel_id: String,
sender_id: String,
content: String,
timestamp: i64,
#[sqlx(default)]
reply_to: Option<String>,
}
impl super::Storage {
pub async fn save_message(&self, msg: &ChatMessagePayload) -> Result<()> {
sqlx::query("INSERT OR IGNORE INTO messages (id,channel_id,sender_id,content,timestamp,reply_to) VALUES (?,?,?,?,?,?)")
.bind(&msg.message_id).bind(&msg.channel_id).bind(&msg.sender_id)
.bind(&msg.content).bind(msg.timestamp).bind(&msg.reply_to)
.execute(&self.pool).await?;
Ok(())
}
pub async fn get_history(
&self,
channel_id: &str,
limit: i64,
) -> Result<Vec<ChatMessagePayload>> {
let rows = sqlx::query_as::<_, MsgRow>(
"SELECT id,channel_id,sender_id,content,timestamp,reply_to FROM \
(SELECT * FROM messages WHERE channel_id=? ORDER BY timestamp DESC LIMIT ?) \
ORDER BY timestamp ASC",
)
.bind(channel_id)
.bind(limit)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| ChatMessagePayload {
message_id: r.id,
channel_id: r.channel_id,
sender_id: r.sender_id,
content: r.content,
timestamp: r.timestamp,
edited: false,
reply_to: r.reply_to,
})
.collect())
}
pub async fn update_read_receipt(
&self,
channel_id: &str,
user_id: &str,
message_id: &str,
) -> Result<()> {
sqlx::query(
"INSERT OR REPLACE INTO read_receipts (channel_id,user_id,last_read_message_id,updated_at) VALUES (?,?,?,?)",
).bind(channel_id).bind(user_id).bind(message_id).bind(super::now_ms())
.execute(&self.pool).await?;
Ok(())
}
pub async fn add_reaction(&self, message_id: &str, user_id: &str, emoji: &str) -> Result<()> {
sqlx::query("INSERT OR IGNORE INTO reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)")
.bind(message_id).bind(user_id).bind(emoji).bind(super::now_ms())
.execute(&self.pool).await?;
Ok(())
}
pub async fn remove_reaction(
&self,
message_id: &str,
user_id: &str,
emoji: &str,
) -> Result<()> {
sqlx::query("DELETE FROM reactions WHERE message_id=? AND user_id=? AND emoji=?")
.bind(message_id)
.bind(user_id)
.bind(emoji)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn has_user_reacted(
&self,
message_id: &str,
user_id: &str,
emoji: &str,
) -> Result<bool> {
Ok(sqlx::query_as::<_, (String,)>(
"SELECT user_id FROM reactions WHERE message_id=? AND user_id=? AND emoji=?",
)
.bind(message_id)
.bind(user_id)
.bind(emoji)
.fetch_optional(&self.pool)
.await?
.is_some())
}
pub async fn edit_message(&self, message_id: &str, new_content: &str) -> Result<()> {
sqlx::query("UPDATE messages SET content=? WHERE id=?")
.bind(new_content)
.bind(message_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn delete_message(&self, message_id: &str) -> Result<()> {
sqlx::query("DELETE FROM messages WHERE id=?")
.bind(message_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_message_sender(&self, message_id: &str) -> Result<Option<String>> {
Ok(
sqlx::query_as::<_, (String,)>("SELECT sender_id FROM messages WHERE id=?")
.bind(message_id)
.fetch_optional(&self.pool)
.await?
.map(|(id,)| id),
)
}
pub async fn get_message(&self, message_id: &str) -> Result<Option<ChatMessagePayload>> {
Ok(sqlx::query_as::<_, MsgRow>(
"SELECT id,channel_id,sender_id,content,timestamp,reply_to FROM messages WHERE id=?",
)
.bind(message_id)
.fetch_optional(&self.pool)
.await?
.map(|r| ChatMessagePayload {
message_id: r.id,
channel_id: r.channel_id,
sender_id: r.sender_id,
content: r.content,
timestamp: r.timestamp,
edited: false,
reply_to: r.reply_to,
}))
}
}

View file

@ -0,0 +1,223 @@
pub mod dm_unread;
pub mod dms;
pub mod guilds;
pub mod messages;
pub mod social;
use anyhow::Result;
use sqlx::SqlitePool;
use tracing::info;
pub struct Storage {
pub pool: SqlitePool,
}
impl Storage {
pub async fn connect(path: &str) -> Result<Self> {
if let Some(p) = std::path::Path::new(path).parent() {
tokio::fs::create_dir_all(p).await?;
}
let pool = SqlitePool::connect(&format!("sqlite://{}?mode=rwc", path)).await?;
let s = Self { pool };
s.migrate().await?;
info!("storage: {path}");
Ok(s)
}
async fn migrate(&self) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY, channel_id TEXT NOT NULL,
sender_id TEXT NOT NULL, content TEXT NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_msg_ch ON messages (channel_id, timestamp);
CREATE TABLE IF NOT EXISTS users (
pubkey TEXT PRIMARY KEY, nickname TEXT NOT NULL, first_seen INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS bans (
pubkey TEXT PRIMARY KEY, reason TEXT, banned_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS direct_messages (
id TEXT PRIMARY KEY,
user1_id TEXT NOT NULL,
user2_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_message_at INTEGER,
unread_count_1 INTEGER NOT NULL DEFAULT 0,
unread_count_2 INTEGER NOT NULL DEFAULT 0,
UNIQUE(user1_id, user2_id)
);
CREATE TABLE IF NOT EXISTS dm_messages (
id TEXT PRIMARY KEY,
dm_id TEXT NOT NULL REFERENCES direct_messages(id),
sender_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dm_msg_dm ON dm_messages(dm_id, created_at);
-- Guild system (Phase 1.2)
CREATE TABLE IF NOT EXISTS guilds (
id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS guild_members (
guild_id TEXT NOT NULL, user_id TEXT NOT NULL,
joined_at INTEGER NOT NULL, PRIMARY KEY(guild_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_gm_user ON guild_members(user_id);
CREATE TABLE IF NOT EXISTS roles (
id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#ffffff',
permissions INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_roles_guild ON roles(guild_id);
CREATE TABLE IF NOT EXISTS invites (
id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, creator_id TEXT NOT NULL,
code TEXT NOT NULL UNIQUE, max_uses INTEGER,
uses INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER, created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code);
CREATE TABLE IF NOT EXISTS member_roles (
guild_id TEXT NOT NULL, user_id TEXT NOT NULL, role_id TEXT NOT NULL,
PRIMARY KEY(guild_id, user_id, role_id)
);
-- Friends system (Phase 1.2)
CREATE TABLE IF NOT EXISTS friend_requests (
id TEXT PRIMARY KEY, from_user_id TEXT NOT NULL, to_user_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING', created_at INTEGER NOT NULL,
UNIQUE(from_user_id, to_user_id)
);
CREATE TABLE IF NOT EXISTS friendships (
user_id_1 TEXT NOT NULL, user_id_2 TEXT NOT NULL,
created_at INTEGER NOT NULL, PRIMARY KEY(user_id_1, user_id_2)
);
CREATE TABLE IF NOT EXISTS read_receipts (
channel_id TEXT NOT NULL, user_id TEXT NOT NULL,
last_read_message_id TEXT NOT NULL, updated_at INTEGER NOT NULL,
PRIMARY KEY(channel_id, user_id)
);
CREATE TABLE IF NOT EXISTS blocks (
blocker_id TEXT NOT NULL, blocked_id TEXT NOT NULL,
created_at INTEGER NOT NULL, PRIMARY KEY(blocker_id, blocked_id)
);
CREATE TABLE IF NOT EXISTS reactions (
message_id TEXT, user_id TEXT, emoji TEXT, created_at INTEGER,
PRIMARY KEY(message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS audit_logs (
id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, actor_id TEXT NOT NULL,
action TEXT NOT NULL, target_id TEXT,
target_type TEXT, reason TEXT, changes TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_guild ON audit_logs(guild_id, created_at);",
)
.execute(&self.pool)
.await?;
// Lightweight migrations for pre-existing databases (idempotent).
self.ensure_column("messages", "reply_to", "TEXT").await?;
Ok(())
}
/// Add a column to a table if it doesn't already exist. Idempotent.
/// Only called with hardcoded literals — safe to bypass sqlx SqlSafeStr check.
async fn ensure_column(
&self,
table: &'static str,
col: &'static str,
decl: &'static str,
) -> Result<()> {
use sqlx::AssertSqlSafe;
// PRAGMA + ALTER can't use bind parameters in SQLite; use AssertSqlSafe
// with hardcoded literals only — never user input.
type PragmaRow = (i64, String, String, i64, Option<String>, i64);
let pragma = format!("PRAGMA table_info({table})");
let rows: Result<Vec<PragmaRow>, _> =
sqlx::query_as::<_, PragmaRow>(AssertSqlSafe(pragma.clone()))
.fetch_all(&self.pool)
.await;
let rows = match rows {
Ok(r) => r,
Err(e) => {
tracing::warn!("ensure_column: PRAGMA failed: {e}");
return Ok(());
}
};
if rows.iter().any(|(_, name, _, _, _, _)| name == col) {
return Ok(());
}
let alter = format!("ALTER TABLE {table} ADD COLUMN {col} {decl}");
sqlx::query(AssertSqlSafe(alter))
.execute(&self.pool)
.await?;
info!("storage: added column {table}.{col}");
Ok(())
}
pub async fn upsert_user(&self, pubkey: &str, nickname: &str) -> Result<()> {
sqlx::query("INSERT OR IGNORE INTO users (pubkey,nickname,first_seen) VALUES (?,?,?)")
.bind(pubkey)
.bind(nickname)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn is_banned(&self, pubkey: &str) -> Result<bool> {
Ok(
sqlx::query_as::<_, (String,)>("SELECT pubkey FROM bans WHERE pubkey=?")
.bind(pubkey)
.fetch_optional(&self.pool)
.await?
.is_some(),
)
}
pub async fn append_audit_log(
&self,
guild_id: &str,
actor_id: &str,
action: &str,
target_id: Option<&str>,
target_type: Option<&str>,
reason: Option<&str>,
) -> Result<()> {
let id = uuid::Uuid::new_v4().to_string();
let now = now_ms();
sqlx::query(
"INSERT INTO audit_logs (id,guild_id,actor_id,action,target_id,target_type,reason,created_at) \
VALUES (?,?,?,?,?,?,?,?)",
)
.bind(&id)
.bind(guild_id)
.bind(actor_id)
.bind(action)
.bind(target_id)
.bind(target_type)
.bind(reason)
.bind(now)
.execute(&self.pool)
.await?;
Ok(())
}
}
pub(crate) fn generate_invite_code() -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let hash = RandomState::new().build_hasher().finish();
format!("{:08x}", hash % 0x100000000u64)
}
pub(crate) fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}

View file

@ -0,0 +1,144 @@
use anyhow::Result;
impl super::Storage {
pub async fn create_friend_request(&self, from_id: &str, to_id: &str) -> Result<bool> {
let (u1, u2) = if from_id < to_id {
(from_id, to_id)
} else {
(to_id, from_id)
};
let exists = sqlx::query_as::<_, (String,)>(
"SELECT user_id_1 FROM friendships WHERE user_id_1=? AND user_id_2=?",
)
.bind(u1)
.bind(u2)
.fetch_optional(&self.pool)
.await?;
if exists.is_some() {
return Ok(false);
}
let id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT OR IGNORE INTO friend_requests (id,from_user_id,to_user_id,status,created_at) VALUES (?,?,?,?,?)"
).bind(&id).bind(from_id).bind(to_id).bind("PENDING").bind(super::now_ms())
.execute(&self.pool).await?;
Ok(true)
}
pub async fn accept_friend_request(&self, from_id: &str, to_id: &str) -> Result<bool> {
let updated = sqlx::query(
"UPDATE friend_requests SET status='ACCEPTED' WHERE from_user_id=? AND to_user_id=? AND status='PENDING'"
).bind(from_id).bind(to_id).execute(&self.pool).await?;
if updated.rows_affected() == 0 {
return Ok(false);
}
let (u1, u2) = if from_id < to_id {
(from_id, to_id)
} else {
(to_id, from_id)
};
sqlx::query(
"INSERT OR IGNORE INTO friendships (user_id_1,user_id_2,created_at) VALUES (?,?,?)",
)
.bind(u1)
.bind(u2)
.bind(super::now_ms())
.execute(&self.pool)
.await?;
Ok(true)
}
pub async fn decline_friend_request(&self, from_id: &str, to_id: &str) -> Result<()> {
sqlx::query("UPDATE friend_requests SET status='DECLINED' WHERE from_user_id=? AND to_user_id=? AND status='PENDING'")
.bind(from_id).bind(to_id).execute(&self.pool).await?;
Ok(())
}
pub async fn remove_friend(&self, user_a: &str, user_b: &str) -> Result<()> {
let (u1, u2) = if user_a < user_b {
(user_a, user_b)
} else {
(user_b, user_a)
};
sqlx::query("DELETE FROM friendships WHERE user_id_1=? AND user_id_2=?")
.bind(u1)
.bind(u2)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn list_friends(&self, user_id: &str) -> Result<Vec<String>> {
let rows1 =
sqlx::query_as::<_, (String,)>("SELECT user_id_2 FROM friendships WHERE user_id_1=?")
.bind(user_id)
.fetch_all(&self.pool)
.await?;
let rows2 =
sqlx::query_as::<_, (String,)>("SELECT user_id_1 FROM friendships WHERE user_id_2=?")
.bind(user_id)
.fetch_all(&self.pool)
.await?;
Ok(rows1.into_iter().chain(rows2).map(|(id,)| id).collect())
}
pub async fn is_friend(&self, user_a: &str, user_b: &str) -> Result<bool> {
let (u1, u2) = if user_a < user_b {
(user_a, user_b)
} else {
(user_b, user_a)
};
Ok(sqlx::query_as::<_, (String,)>(
"SELECT user_id_1 FROM friendships WHERE user_id_1=? AND user_id_2=?",
)
.bind(u1)
.bind(u2)
.fetch_optional(&self.pool)
.await?
.is_some())
}
pub async fn block_user(&self, blocker: &str, blocked: &str) -> Result<()> {
sqlx::query(
"INSERT OR IGNORE INTO blocks (blocker_id,blocked_id,created_at) VALUES (?,?,?)",
)
.bind(blocker)
.bind(blocked)
.bind(super::now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn unblock_user(&self, blocker: &str, blocked: &str) -> Result<()> {
sqlx::query("DELETE FROM blocks WHERE blocker_id=? AND blocked_id=?")
.bind(blocker)
.bind(blocked)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn is_blocked(&self, blocker: &str, blocked: &str) -> Result<bool> {
Ok(sqlx::query_as::<_, (String,)>(
"SELECT blocker_id FROM blocks WHERE blocker_id=? AND blocked_id=?",
)
.bind(blocker)
.bind(blocked)
.fetch_optional(&self.pool)
.await?
.is_some())
}
pub async fn list_blocks(&self, blocker: &str) -> Result<Vec<String>> {
Ok(
sqlx::query_as::<_, (String,)>("SELECT blocked_id FROM blocks WHERE blocker_id=?")
.bind(blocker)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(|(id,)| id)
.collect(),
)
}
}

View file

@ -0,0 +1,251 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tracing::{info, warn};
use crate::{
domain::{channels, session},
net::{
io,
state::{BroadcastMsg, State},
},
proto::{
ChannelCreatePayload, ChannelDeletePayload, ChannelListItem, ChannelListPayload,
ChannelStatePayload, PacketId, SessionCrypto, encode_packet, to_payload,
},
};
/// Handle a ChannelCreate request — register a new channel in the store and
/// broadcast the new ChannelState to all sessions so their sidebars update.
pub async fn handle_channel_create(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: ChannelCreatePayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
// Validate kind.
let kind = match req.kind.as_str() {
"text" => channels::ChannelKind::Text,
"voice" => channels::ChannelKind::Voice,
other => {
warn!(
"channel_create from {}: invalid kind '{other}'",
sess.nickname
);
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::InvalidPacket as u32,
message: format!("invalid channel kind: {other}"),
}),
crypto,
)
.await?;
return Ok(());
}
};
let channel_id = req.channel_id.trim().to_string();
let channel_name = if req.channel_name.trim().is_empty() {
channel_id.clone()
} else {
req.channel_name.trim().to_string()
};
if channel_id.is_empty() {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::InvalidPacket as u32,
message: "channel_id is required".into(),
}),
crypto,
)
.await?;
return Ok(());
}
// Limit channel-create rate (reuse the per-session token bucket — 1 token
// means 1 channel-create per rate window).
if !state.rate_limiter.try_consume(session_id) {
state.metrics.inc(&state.metrics.rate_limited_events);
warn!("rate-limited channel_create from {}", sess.nickname);
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::RateLimited as u32,
message: "slow down — too many channel operations".into(),
}),
crypto,
)
.await?;
return Ok(());
}
let created = channels::create(&state.channels, &channel_id, &channel_name, kind.clone()).await;
if !created {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::ChannelNotFound as u32,
message: "channel already exists".into(),
}),
crypto,
)
.await?;
return Ok(());
}
info!(
"channel_create: {} created '{}' ({}) by {}",
channel_id,
channel_name,
kind.as_str(),
sess.nickname
);
// Reply to creator with ChannelState (no members yet).
let sp = ChannelStatePayload {
channel_id: channel_id.clone(),
channel_name: channel_name.clone(),
kind: kind.as_str().into(),
members: Vec::new(),
voice_endpoint: state.config.voice.bind.clone(),
};
io::send_encrypted(
stream,
PacketId::ChannelState,
seq,
&to_payload(&sp),
crypto,
)
.await?;
// Broadcast a ChannelCreate event to all other sessions so their sidebars update.
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(
PacketId::ChannelCreate,
0,
&to_payload(&ChannelCreatePayload {
channel_id: channel_id.clone(),
channel_name: channel_name.clone(),
kind: kind.as_str().into(),
guild_id: req.guild_id.clone(),
}),
),
});
Ok(())
}
/// Handle a ChannelDelete request — remove the channel from the store and
/// broadcast the deletion to all sessions.
pub async fn handle_channel_delete(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: ChannelDeletePayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
// Protect default channels from deletion.
if req.channel_id == "general" || req.channel_id == "voice" {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::PermissionDenied as u32,
message: "cannot delete default channels".into(),
}),
crypto,
)
.await?;
return Ok(());
}
let existed = channels::delete(&state.channels, &req.channel_id).await;
if !existed {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::ChannelNotFound as u32,
message: "channel not found".into(),
}),
crypto,
)
.await?;
return Ok(());
}
info!(
"channel_delete: '{}' removed by {}",
req.channel_id, sess.nickname
);
// Broadcast deletion to all sessions.
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(
PacketId::ChannelDelete,
0,
&to_payload(&ChannelDeletePayload {
channel_id: req.channel_id.clone(),
}),
),
});
Ok(())
}
/// Handle a ChannelList request — reply with all known channels.
pub async fn handle_channel_list(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let _sess = session::get(&state.sessions, session_id).await;
let channels = channels::list(&state.channels).await;
let items: Vec<ChannelListItem> = channels
.iter()
.map(|c| ChannelListItem {
channel_id: c.id.clone(),
channel_name: c.name.clone(),
kind: c.kind.as_str().into(),
})
.collect();
let p = ChannelListPayload { channels: items };
io::send_encrypted(stream, PacketId::ChannelList, seq, &to_payload(&p), crypto).await?;
Ok(())
}

View file

@ -0,0 +1,115 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tracing::info;
use crate::{
domain::{channels, session},
net::{
io,
state::{BroadcastMsg, State},
},
proto::{
self, ChannelStatePayload, ChatHistoryPayload, MemberInfo, PacketId, SessionCrypto,
UserJoinPayload, encode_packet, to_payload,
},
};
use super::{broadcast_leave, set_channel};
const HISTORY_LIMIT: i64 = 50;
pub async fn join(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
channel_id: &str,
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let prev_channel = session::get(&state.sessions, session_id)
.await
.and_then(|s| s.channel_id);
if prev_channel.as_deref() == Some(channel_id) {
return Ok(());
}
if let Some(prev) = prev_channel {
channels::leave(&state.channels, &prev, session_id).await;
broadcast_leave(state, &prev, session_id).await;
}
let ch = match channels::get_channel(&state.channels, channel_id).await {
Some(c) => c,
None => {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&proto::ErrorPayload {
code: proto::ErrorCode::ChannelNotFound as u32,
message: "not found".into(),
}),
crypto,
)
.await?;
return Ok(());
}
};
channels::join(&state.channels, channel_id, session_id).await;
set_channel(state, session_id, Some(channel_id.into())).await;
let mut members = Vec::new();
for sid in channels::members(&state.channels, channel_id).await {
if let Some(s) = session::get(&state.sessions, &sid).await {
members.push(MemberInfo {
user_id: s.user_id.clone(),
nickname: s.nickname.clone(),
in_voice: ch.kind == channels::ChannelKind::Voice,
});
}
}
let sp = ChannelStatePayload {
channel_id: ch.id.clone(),
channel_name: ch.name.clone(),
kind: ch.kind.as_str().into(),
members,
voice_endpoint: state.config.voice.bind.clone(),
};
io::send_encrypted(
stream,
PacketId::ChannelState,
seq,
&to_payload(&sp),
crypto,
)
.await?;
let history = state.storage.get_history(channel_id, HISTORY_LIMIT).await?;
if !history.is_empty() {
let hp = ChatHistoryPayload {
channel_id: channel_id.into(),
messages: history,
};
io::send_encrypted(stream, PacketId::ChatHistory, seq, &to_payload(&hp), crypto).await?;
}
if let Some(sess) = session::get(&state.sessions, session_id).await {
let jp = UserJoinPayload {
channel_id: channel_id.into(),
user_id: sess.user_id.clone(),
nickname: sess.nickname.clone(),
};
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(channel_id.into()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::UserJoin, 0, &to_payload(&jp)),
});
}
info!("session {} joined {channel_id}", &session_id[..8]);
Ok(())
}

View file

@ -0,0 +1,22 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tracing::info;
use crate::{domain::channels, net::state::State, proto::SessionCrypto};
use super::{broadcast_leave, set_channel};
pub async fn leave(
_stream: &mut TcpStream,
_seq: &mut u32,
session_id: &str,
channel_id: &str,
_crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
channels::leave(&state.channels, channel_id, session_id).await;
set_channel(state, session_id, None).await;
broadcast_leave(state, channel_id, session_id).await;
info!("session {} left {channel_id}", &session_id[..8]);
Ok(())
}

View file

@ -0,0 +1,33 @@
pub mod create;
pub mod join;
pub mod leave;
pub use create::{handle_channel_create, handle_channel_delete, handle_channel_list};
pub use join::join;
pub use leave::leave;
use crate::{
net::state::{BroadcastMsg, State},
proto::{PacketId, UserLeavePayload, encode_packet, to_payload},
};
pub async fn broadcast_leave(state: &State, channel_id: &str, session_id: &str) {
if let Some(sess) = crate::domain::session::get(&state.sessions, session_id).await {
let p = UserLeavePayload {
channel_id: channel_id.into(),
user_id: sess.user_id.clone(),
};
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(channel_id.into()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::UserLeave, 0, &to_payload(&p)),
});
}
}
async fn set_channel(state: &State, session_id: &str, ch: Option<String>) {
if let Some(s) = state.sessions.write().await.get_mut(session_id) {
s.channel_id = ch;
}
}

View file

@ -0,0 +1,66 @@
use anyhow::Result;
use tracing::{debug, warn};
use crate::{
net::state::{BroadcastMsg, State},
proto::{ChatMessagePayload, ErrorCode, PacketId, encode_packet, to_payload},
};
pub async fn handle(session_id: &str, mut msg: ChatMessagePayload, state: &State) -> Result<()> {
let sess = match crate::domain::session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if sess.channel_id.as_deref() != Some(&msg.channel_id) {
return Ok(());
}
// Rate limit check (token bucket per session).
if !state.rate_limiter.try_consume(session_id) {
state.metrics.inc(&state.metrics.rate_limited_events);
warn!("rate-limited session {session_id}");
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: None,
target_session_id: Some(session_id.to_string()),
data: encode_packet(
PacketId::Error,
0,
&to_payload(&crate::proto::ErrorPayload {
code: ErrorCode::RateLimited as u32,
message: "you are sending messages too quickly".into(),
}),
),
});
return Ok(());
}
msg.sender_id = sess.user_id.clone();
if msg.timestamp == 0 {
msg.timestamp = now_ms();
}
state.storage.save_message(&msg).await?;
state.metrics.inc(&state.metrics.messages_sent);
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(msg.channel_id.clone()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::ChatMessage, 0, &to_payload(&msg)),
});
debug!(
"chat {} → {}: {}",
sess.nickname, msg.channel_id, msg.content
);
Ok(())
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}

View file

@ -0,0 +1,93 @@
use anyhow::Result;
use tracing::debug;
use crate::{
domain::session,
net::state::{BroadcastMsg, State},
proto::{ChatMessagePayload, MessageDeletePayload, PacketId, encode_packet, to_payload},
};
pub async fn handle_message_edit(session_id: &str, payload: &[u8], state: &State) -> Result<()> {
let edit: crate::proto::MessageEditPayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if sess.channel_id.as_deref() != Some(&edit.channel_id) {
return Ok(());
}
// Verify sender owns the message
let sender = state.storage.get_message_sender(&edit.message_id).await?;
if sender.as_deref() != Some(&sess.user_id) {
return Ok(());
}
let msg = state.storage.get_message(&edit.message_id).await?;
let msg = match msg {
Some(m) => m,
None => return Ok(()),
};
state
.storage
.edit_message(&edit.message_id, &edit.content)
.await?;
let broadcast_msg = ChatMessagePayload {
message_id: edit.message_id.clone(),
channel_id: edit.channel_id.clone(),
sender_id: sess.user_id.clone(),
content: edit.content,
timestamp: msg.timestamp,
edited: true,
reply_to: msg.reply_to.clone(),
};
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(edit.channel_id.clone()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::MessageEdit, 0, &to_payload(&broadcast_msg)),
});
debug!(
"edit {} {}:{}",
sess.nickname, edit.channel_id, edit.message_id
);
Ok(())
}
pub async fn handle_message_delete(session_id: &str, payload: &[u8], state: &State) -> Result<()> {
let delete: MessageDeletePayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if sess.channel_id.as_deref() != Some(&delete.channel_id) {
return Ok(());
}
// Verify sender owns the message
let sender = state.storage.get_message_sender(&delete.message_id).await?;
if sender.as_deref() != Some(&sess.user_id) {
return Ok(());
}
state.storage.delete_message(&delete.message_id).await?;
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(delete.channel_id.clone()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::MessageDelete, 0, &to_payload(&delete)),
});
debug!(
"delete {} {}:{}",
sess.nickname, delete.channel_id, delete.message_id
);
Ok(())
}

View file

@ -0,0 +1,9 @@
pub mod chat;
pub mod message_edit;
pub mod presence;
pub mod reaction;
pub mod read_receipt;
pub mod typing;
pub use read_receipt::handle_read_receipt;
pub use typing::handle_typing_start;

View file

@ -0,0 +1,84 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{
io,
state::{BroadcastMsg, State},
},
proto::{
PacketId, PresenceEventPayload, PresenceInfo, PresenceSyncPayload, PresenceUpdatePayload,
SessionCrypto, encode_packet, to_payload,
},
};
pub async fn handle_presence_update(
_stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
_crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: PresenceUpdatePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let info = PresenceInfo {
user_id: sess.user_id.clone(),
nickname: sess.nickname.clone(),
status: req.status.clone(),
activity_type: req.activity_type,
activity_text: req.activity_text,
};
state
.presences
.write()
.await
.insert(sess.user_id.clone(), info.clone());
// Broadcast to everyone (friends/guild-mates will filter client-side for now)
let event = PresenceEventPayload {
user_id: info.user_id,
nickname: info.nickname,
status: info.status,
activity_type: info.activity_type,
activity_text: info.activity_text,
};
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::PresenceEvent, *seq, &to_payload(&event)),
});
Ok(())
}
pub async fn handle_presence_sync(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let _sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let presences: Vec<PresenceInfo> = state.presences.read().await.values().cloned().collect();
io::send_encrypted(
stream,
PacketId::PresenceSync,
seq,
&to_payload(&PresenceSyncPayload { presences }),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,92 @@
use anyhow::Result;
use tracing::debug;
use crate::{
domain::session,
net::state::{BroadcastMsg, State},
proto::{PacketId, ReactionPayload, encode_packet, to_payload},
};
pub async fn handle_reaction_add(
session_id: &str,
mut payload: ReactionPayload,
state: &State,
) -> Result<()> {
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if sess.channel_id.as_deref() != Some(&payload.channel_id) {
return Ok(());
}
// Verify message exists in the channel
let msg = state.storage.get_message(&payload.message_id).await?;
if msg.is_none() || msg.as_ref().unwrap().channel_id != payload.channel_id {
return Ok(());
}
payload.user_id = sess.user_id.clone();
state
.storage
.add_reaction(&payload.message_id, &sess.user_id, &payload.emoji)
.await?;
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(payload.channel_id.clone()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::MessageReactionAdd, 0, &to_payload(&payload)),
});
debug!(
"reaction {} +{} on {}",
sess.nickname, payload.emoji, payload.message_id
);
Ok(())
}
pub async fn handle_reaction_remove(
session_id: &str,
mut payload: ReactionPayload,
state: &State,
) -> Result<()> {
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if sess.channel_id.as_deref() != Some(&payload.channel_id) {
return Ok(());
}
payload.user_id = sess.user_id.clone();
// Check user owns the reaction
let has = state
.storage
.has_user_reacted(&payload.message_id, &sess.user_id, &payload.emoji)
.await?;
if !has {
return Ok(());
}
state
.storage
.remove_reaction(&payload.message_id, &sess.user_id, &payload.emoji)
.await?;
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(payload.channel_id.clone()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::MessageReactionRemove, 0, &to_payload(&payload)),
});
debug!(
"reaction {} -{} on {}",
sess.nickname, payload.emoji, payload.message_id
);
Ok(())
}

View file

@ -0,0 +1,46 @@
use anyhow::Result;
use crate::{
domain::session,
net::state::{BroadcastMsg, State},
proto::{PacketId, ReadReceiptPayload, encode_packet, to_payload},
};
pub async fn handle_read_receipt(
_stream: &mut tokio::net::TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
_crypto: &crate::proto::SessionCrypto,
state: &State,
) -> Result<()> {
let req: ReadReceiptPayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
state
.storage
.update_read_receipt(&req.channel_id, &sess.user_id, &req.last_read_message_id)
.await?;
let broadcast_data = serde_json::json!({
"channel_id": req.channel_id,
"user_id": sess.user_id,
"last_read_message_id": req.last_read_message_id,
});
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(req.channel_id),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(
PacketId::ReadReceiptBroadcast,
*seq,
&to_payload(&broadcast_data),
),
});
Ok(())
}

View file

@ -0,0 +1,30 @@
use anyhow::Result;
use crate::{
domain::session,
net::state::{BroadcastMsg, State},
proto::{PacketId, TypingStartPayload, encode_packet, to_payload},
};
pub async fn handle_typing_start(session_id: &str, payload: &[u8], state: &State) -> Result<()> {
let req: TypingStartPayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
let data = serde_json::json!({
"user_id": sess.user_id,
"nickname": sess.nickname,
"channel_id": req.channel_id,
});
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(req.channel_id),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::TypingStart, 0, &to_payload(&data)),
});
Ok(())
}

View file

@ -0,0 +1,39 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{io, state::BroadcastMsg, state::State},
proto::SessionCrypto,
};
pub async fn deliver_encrypted(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
msg: &BroadcastMsg,
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
if msg.exclude_session.as_deref() == Some(session_id) {
return Ok(());
}
if let Some(ref target) = msg.target_session_id {
if target != session_id {
return Ok(());
}
io::deliver_encrypted(stream, crypto, seq, &msg.data).await?;
return Ok(());
}
if let Some(ref ch) = msg.channel_id {
let in_ch = session::get(&state.sessions, session_id)
.await
.and_then(|s| s.channel_id)
.as_deref()
== Some(ch.as_str());
if !in_ch {
return Ok(());
}
}
io::deliver_encrypted(stream, crypto, seq, &msg.data).await
}

View file

@ -0,0 +1,46 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{io, state::State},
proto::{DmHistoryPayload, PacketId, SessionCrypto, to_payload},
};
pub async fn handle_dm_history(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: DmHistoryPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let my_id = sess.user_id.clone();
drop(sess);
let _other_id = state
.storage
.get_dm_user_id(&req.dm_id, &my_id)
.await?
.ok_or_else(|| anyhow::anyhow!("user not in DM"))?;
let limit = req.limit.unwrap_or(50);
let messages = state
.storage
.get_dm_messages(&req.dm_id, limit, req.search_query.as_deref(), None)
.await?;
let resp = DmHistoryPayload {
dm_id: req.dm_id,
messages,
search_query: None,
limit: None,
};
io::send_encrypted(stream, PacketId::DmHistory, seq, &to_payload(&resp), crypto).await?;
Ok(())
}

View file

@ -0,0 +1,7 @@
mod history;
mod send;
mod start;
pub use history::handle_dm_history;
pub use send::handle_dm_message;
pub use start::{handle_dm_read_ack, handle_dm_start};

View file

@ -0,0 +1,113 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tracing::warn;
use crate::{
domain::session,
net::{
io,
state::{BroadcastMsg, State},
},
proto::{
self, DmMessagePayload, ErrorCode, PacketId, SessionCrypto, encode_packet, to_payload,
},
};
pub async fn handle_dm_message(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let msg: DmMessagePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let my_id = sess.user_id.clone();
drop(sess);
// Rate limit check (token bucket per session).
if !state.rate_limiter.try_consume(session_id) {
state.metrics.inc(&state.metrics.rate_limited_events);
warn!("rate-limited DM session {session_id}");
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&proto::ErrorPayload {
code: ErrorCode::RateLimited as u32,
message: "you are sending messages too quickly".into(),
}),
crypto,
)
.await?;
return Ok(());
}
let other_id = state
.storage
.get_dm_user_id(&msg.dm_id, &my_id)
.await?
.ok_or_else(|| anyhow::anyhow!("user not in DM"))?;
if state.storage.is_blocked(&other_id, &my_id).await? {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&proto::ErrorPayload {
code: proto::ErrorCode::Blocked as u32,
message: "blocked".into(),
}),
crypto,
)
.await?;
return Ok(());
}
let saved = state
.storage
.save_dm_message(&msg.dm_id, &my_id, &msg.content)
.await?;
state.metrics.inc(&state.metrics.dm_messages_sent);
state
.storage
.increment_dm_unread(&msg.dm_id, &other_id)
.await?;
if let Some(recipient_sid) =
session::get_session_id_by_user_id(&state.sessions, &other_id).await
{
let data = encode_packet(
PacketId::DmMessage,
0,
&to_payload(&DmMessagePayload {
dm_id: msg.dm_id.clone(),
sender_id: my_id.clone(),
content: msg.content.clone(),
timestamp: saved.timestamp,
}),
);
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: None,
target_session_id: Some(recipient_sid),
data,
});
} else {
warn!("dm recipient offline: {}", &other_id[..8]);
}
io::send_encrypted(
stream,
PacketId::DmMessage,
seq,
&to_payload(&saved),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,113 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{io, state::State},
proto::{self, DmStartPayload, DmStartResponsePayload, PacketId, SessionCrypto, to_payload},
};
pub async fn handle_dm_start(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: DmStartPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let my_id = sess.user_id.clone();
drop(sess);
if state
.storage
.is_blocked(&req.target_user_id, &my_id)
.await?
{
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&proto::ErrorPayload {
code: proto::ErrorCode::Blocked as u32,
message: "blocked".into(),
}),
crypto,
)
.await?;
return Ok(());
}
if req.target_user_id == my_id {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&proto::ErrorPayload {
code: proto::ErrorCode::InvalidPacket as u32,
message: "cannot DM yourself".into(),
}),
crypto,
)
.await?;
return Ok(());
}
let (dm_id, unread_count) = state
.storage
.find_or_create_dm(&my_id, &req.target_user_id)
.await?;
let other_id = state
.storage
.get_dm_user_id(&dm_id, &my_id)
.await?
.ok_or_else(|| anyhow::anyhow!("user not in DM"))?;
let nickname = state
.storage
.get_dm_nickname(&other_id)
.await?
.unwrap_or_else(|| other_id[..8].to_string());
let messages = state
.storage
.get_dm_messages(&dm_id, 50, None, None)
.await?;
let resp = DmStartResponsePayload {
dm_id,
other_user_id: other_id,
other_nickname: nickname,
messages,
unread_count: unread_count as u32,
};
io::send_encrypted(stream, PacketId::DmStart, seq, &to_payload(&resp), crypto).await?;
Ok(())
}
pub async fn handle_dm_read_ack(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: serde_json::Value = serde_json::from_slice(payload)?;
let dm_id = req["dm_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing dm_id"))?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let my_id = sess.user_id.clone();
drop(sess);
state.storage.reset_dm_unread(dm_id, &my_id).await?;
io::send_encrypted(stream, PacketId::DmReadAck, seq, b"{}", crypto).await?;
Ok(())
}

View file

@ -0,0 +1,187 @@
use anyhow::Result;
use tracing::debug;
use crate::{
net::io,
proto::{
ChatMessagePayload, JoinChannelPayload, LeaveChannelPayload, PacketId, PingPayload,
PongPayload, ReactionPayload, to_payload,
},
};
use super::{Ctx, channel, content, direct_message, friends, guild};
pub async fn dispatch(
ctx: &mut Ctx<'_>,
session_id: &str,
pid: PacketId,
payload: &[u8],
addr: std::net::SocketAddr,
) -> Result<()> {
let Ctx {
stream,
seq,
crypto,
state,
} = ctx;
match pid {
PacketId::Ping => {
let ping: PingPayload = serde_json::from_slice(payload)?;
io::send_encrypted(
stream,
PacketId::Pong,
seq,
&to_payload(&PongPayload {
timestamp: ping.timestamp,
}),
crypto,
)
.await?;
}
PacketId::JoinChannel => {
let m: JoinChannelPayload = serde_json::from_slice(payload)?;
channel::join(stream, seq, session_id, &m.channel_id, crypto, state).await?;
}
PacketId::LeaveChannel => {
let m: LeaveChannelPayload = serde_json::from_slice(payload)?;
channel::leave(stream, seq, session_id, &m.channel_id, crypto, state).await?;
}
PacketId::ChannelCreate => {
channel::handle_channel_create(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::ChannelDelete => {
channel::handle_channel_delete(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::ChannelList => {
channel::handle_channel_list(stream, seq, session_id, crypto, state).await?;
}
PacketId::ChatMessage => {
let m: ChatMessagePayload = serde_json::from_slice(payload)?;
content::chat::handle(session_id, m, state).await?;
}
PacketId::DmStart => {
direct_message::handle_dm_start(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::DmMessage => {
direct_message::handle_dm_message(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::DmHistory => {
direct_message::handle_dm_history(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::DmReadAck => {
direct_message::handle_dm_read_ack(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::GuildCreate => {
guild::handle_guild_create(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::GuildDelete => {
guild::handle_guild_delete(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::GuildList => {
guild::handle_guild_list(stream, seq, session_id, crypto, state).await?;
}
PacketId::GuildMemberJoin => {
guild::handle_guild_member_join(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::GuildMemberLeave => {
guild::handle_guild_member_leave(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::GuildMemberKick => {
guild::handle_guild_member_kick(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::RoleCreate => {
guild::handle_role_create(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::RoleDelete => {
guild::handle_role_delete(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::InviteCreate => {
guild::handle_invite_create(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::InviteAccept => {
guild::handle_invite_accept(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::InviteDelete => {
guild::handle_invite_delete(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::GuildAuditLogFetch => {
guild::handle_audit_log_fetch(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::GuildMemberListFetch => {
guild::handle_member_list_fetch(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::GuildRoleAssign => {
guild::handle_role_assign(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::GuildRoleUnassign => {
guild::handle_role_unassign(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::GuildRoleListFetch => {
guild::handle_role_list_fetch(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::PresenceUpdate => {
content::presence::handle_presence_update(
stream, seq, session_id, payload, crypto, state,
)
.await?;
}
PacketId::PresenceSync => {
content::presence::handle_presence_sync(stream, seq, session_id, crypto, state).await?;
}
PacketId::FriendRequest => {
friends::handle_friend_request(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::FriendAccept => {
friends::handle_friend_accept(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::FriendDecline => {
friends::handle_friend_decline(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::FriendRemove => {
friends::handle_friend_remove(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::FriendList => {
friends::handle_friend_list(stream, seq, session_id, crypto, state).await?;
}
PacketId::BlockUser => {
friends::handle_block_user(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::UnblockUser => {
friends::handle_unblock_user(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::BlockList => {
friends::handle_block_list(stream, seq, session_id, crypto, state).await?;
}
PacketId::MessageReactionAdd => {
let m: ReactionPayload = serde_json::from_slice(payload)?;
content::reaction::handle_reaction_add(session_id, m, state).await?;
}
PacketId::MessageReactionRemove => {
let m: ReactionPayload = serde_json::from_slice(payload)?;
content::reaction::handle_reaction_remove(session_id, m, state).await?;
}
PacketId::MessageEdit => {
content::message_edit::handle_message_edit(session_id, payload, state).await?;
}
PacketId::MessageDelete => {
content::message_edit::handle_message_delete(session_id, payload, state).await?;
}
PacketId::TypingStart => {
content::handle_typing_start(session_id, payload, state).await?;
}
PacketId::ReadReceipt => {
content::handle_read_receipt(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::Disconnect => debug!("{addr} DISCONNECT"),
other => debug!("{addr} unhandled {:?}", other),
}
Ok(())
}

View file

@ -0,0 +1,37 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::io,
proto::{FriendDeclinePayload, PacketId, SessionCrypto, to_payload},
};
pub async fn handle_friend_decline(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &crate::net::state::State,
) -> Result<()> {
let req: FriendDeclinePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
state
.storage
.decline_friend_request(&req.from_user_id, &sess.user_id)
.await?;
io::send_encrypted(
stream,
PacketId::FriendDecline,
seq,
&to_payload(&serde_json::json!({"status": "DECLINED"})),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,47 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{io, state::State},
proto::{FriendInfo, FriendListPayload, PacketId, SessionCrypto, to_payload},
};
pub async fn handle_friend_list(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let friend_ids = state.storage.list_friends(&sess.user_id).await?;
let mut friends = Vec::new();
let presences = state.presences.read().await;
for fid in &friend_ids {
let nick = state.storage.get_nickname(fid).await?.unwrap_or_default();
let presence = presences.get(fid);
friends.push(FriendInfo {
user_id: fid.clone(),
nickname: nick,
status: presence
.map(|p| p.status.clone())
.unwrap_or_else(|| "OFFLINE".into()),
since: 0,
});
}
io::send_encrypted(
stream,
PacketId::FriendList,
seq,
&to_payload(&FriendListPayload { friends }),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,122 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{io, state::State},
proto::{
BlockListPayload, BlockUserPayload, FriendRemovePayload, PacketId, SessionCrypto,
UnblockUserPayload, to_payload,
},
};
pub async fn handle_friend_remove(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: FriendRemovePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
state
.storage
.remove_friend(&sess.user_id, &req.user_id)
.await?;
io::send_encrypted(
stream,
PacketId::FriendRemove,
seq,
&to_payload(&serde_json::json!({"removed": true})),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_block_user(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: BlockUserPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
state
.storage
.block_user(&sess.user_id, &req.user_id)
.await?;
io::send_encrypted(
stream,
PacketId::BlockUser,
seq,
&to_payload(&serde_json::json!({"blocked": true})),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_unblock_user(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: UnblockUserPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
state
.storage
.unblock_user(&sess.user_id, &req.user_id)
.await?;
io::send_encrypted(
stream,
PacketId::UnblockUser,
seq,
&to_payload(&serde_json::json!({"unblocked": true})),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_block_list(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let blocked = state.storage.list_blocks(&sess.user_id).await?;
io::send_encrypted(
stream,
PacketId::BlockList,
seq,
&to_payload(&BlockListPayload { blocked }),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,37 @@
mod decline;
mod list;
mod manage;
mod requests;
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
net::io,
proto::{ErrorPayload, PacketId, SessionCrypto, to_payload},
};
async fn send_err(
stream: &mut TcpStream,
seq: &mut u32,
code: crate::proto::ErrorCode,
msg: &str,
crypto: &SessionCrypto,
) -> Result<()> {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&ErrorPayload {
code: code as u32,
message: msg.into(),
}),
crypto,
)
.await
}
pub use decline::handle_friend_decline;
pub use list::handle_friend_list;
pub use manage::{handle_block_list, handle_block_user, handle_friend_remove, handle_unblock_user};
pub use requests::{handle_friend_accept, handle_friend_request};

View file

@ -0,0 +1,152 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{
io,
state::{BroadcastMsg, State},
},
proto::{
FriendAcceptPayload, FriendEventPayload, FriendRequestPayload, PacketId, SessionCrypto,
encode_packet, to_payload,
},
};
use super::send_err;
pub async fn handle_friend_request(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: FriendRequestPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if state
.storage
.is_blocked(&req.to_user_id, &sess.user_id)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::Blocked,
"you are blocked by this user",
crypto,
)
.await?;
return Ok(());
}
if state
.storage
.is_friend(&sess.user_id, &req.to_user_id)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::InvalidPacket,
"already friends",
crypto,
)
.await?;
return Ok(());
}
let ok = state
.storage
.create_friend_request(&sess.user_id, &req.to_user_id)
.await?;
if !ok {
send_err(
stream,
seq,
crate::proto::ErrorCode::InvalidPacket,
"already friends or request pending",
crypto,
)
.await?;
return Ok(());
}
let ev = FriendEventPayload {
event: "REQUEST_RECEIVED".into(),
user_id: sess.user_id.clone(),
nickname: Some(sess.nickname.clone()),
};
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: None,
target_session_id: Some(req.to_user_id.clone()),
data: encode_packet(PacketId::FriendRequest, *seq, &to_payload(&ev)),
});
io::send_encrypted(
stream,
PacketId::FriendRequest,
seq,
&to_payload(&serde_json::json!({"status": "PENDING", "to_user_id": req.to_user_id})),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_friend_accept(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: FriendAcceptPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let ok = state
.storage
.accept_friend_request(&req.from_user_id, &sess.user_id)
.await?;
if !ok {
send_err(
stream,
seq,
crate::proto::ErrorCode::InvalidPacket,
"no pending request",
crypto,
)
.await?;
return Ok(());
}
let ev = FriendEventPayload {
event: "REQUEST_ACCEPTED".into(),
user_id: sess.user_id.clone(),
nickname: Some(sess.nickname.clone()),
};
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: None,
target_session_id: Some(req.from_user_id.clone()),
data: encode_packet(PacketId::FriendAccept, *seq, &to_payload(&ev)),
});
io::send_encrypted(
stream,
PacketId::FriendAccept,
seq,
&to_payload(&serde_json::json!({"status": "ACCEPTED", "from_user_id": req.from_user_id})),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,86 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::permissions::Permissions,
net::{io, state::State},
proto::{
AuditLogEntryPayload, GuildAuditLogFetchPayload, GuildAuditLogPayload, PacketId,
SessionCrypto, to_payload,
},
};
/// Fetch audit log entries for a guild (admin-only: requires VIEW_AUDIT_LOG or owner).
pub async fn handle_audit_log_fetch(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildAuditLogFetchPayload = serde_json::from_slice(payload)?;
let sess = match crate::domain::session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
// Permission check: owner always passes; otherwise require VIEW_AUDIT_LOG.
// We reuse the existing helper via the same logic.
let allowed = if let Some(g) = state.storage.get_guild(&req.guild_id).await?
&& g.owner_id == sess.user_id
{
true
} else {
// No VIEW_AUDIT_LOG bit defined yet — fall back to MANAGE_GUILD.
let perms = state
.storage
.get_user_role_perms(&req.guild_id, &sess.user_id)
.await?;
Permissions::from_role_perms(&perms).has(Permissions::MANAGE_GUILD)
};
if !allowed {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::PermissionDenied as u32,
message: "audit log access requires MANAGE_GUILD".into(),
}),
crypto,
)
.await?;
return Ok(());
}
let limit = req.limit.clamp(1, 200);
let rows = state.storage.get_audit_log(&req.guild_id, limit).await?;
let entries: Vec<AuditLogEntryPayload> = rows
.into_iter()
.map(|r| AuditLogEntryPayload {
id: r.id,
guild_id: r.guild_id,
actor_id: r.actor_id,
action: r.action,
target_id: r.target_id,
target_type: r.target_type,
reason: r.reason,
created_at: r.created_at,
})
.collect();
let resp = GuildAuditLogPayload {
guild_id: req.guild_id,
entries,
};
io::send_encrypted(
stream,
PacketId::GuildAuditLog,
seq,
&to_payload(&resp),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,124 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tracing::debug;
use crate::{
domain::session,
net::{io, state::State},
proto::{GuildCreatePayload, GuildInfo, PacketId, SessionCrypto, to_payload},
};
use super::send_err;
pub async fn handle_guild_create(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildCreatePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if req.name.len() < 2 || req.name.len() > 100 {
send_err(
stream,
seq,
crate::proto::ErrorCode::InvalidPacket,
"guild name must be 2-100 chars",
crypto,
)
.await?;
return Ok(());
}
let guild_id = state.storage.create_guild(&sess.user_id, &req.name).await?;
let guild = state
.storage
.get_guild(&guild_id)
.await?
.ok_or_else(|| anyhow::anyhow!("guild not found after create"))?;
let info = GuildInfo {
id: guild.id.clone(),
owner_id: guild.owner_id,
name: guild.name,
member_count: guild.member_count,
created_at: guild.created_at,
};
io::send_encrypted(
stream,
PacketId::GuildCreate,
seq,
&to_payload(&info),
crypto,
)
.await?;
debug!(
"guild: {} created {} (owner={})",
sess.nickname, guild_id, sess.user_id
);
Ok(())
}
pub async fn handle_guild_delete(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
#[derive(serde::Deserialize)]
struct Req {
guild_id: String,
}
let req: Req = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let guild = state
.storage
.get_guild(&req.guild_id)
.await?
.ok_or_else(|| anyhow::anyhow!("guild not found"))?;
if guild.owner_id != sess.user_id {
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"only owner can delete guild",
crypto,
)
.await?;
return Ok(());
}
state.storage.delete_guild(&req.guild_id).await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"GUILD_DELETE",
None,
None,
None,
)
.await?;
io::send_encrypted(
stream,
PacketId::GuildDelete,
seq,
&to_payload(&serde_json::json!({"guild_id": req.guild_id})),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,219 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::{permissions::Permissions, session},
net::{io, state::State},
proto::{
InviteAcceptPayload, InviteCreatePayload, InviteDeletePayload, InviteInfo, PacketId,
SessionCrypto, to_payload,
},
};
use super::{now_ms, send_err};
pub async fn handle_invite_create(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: InviteCreatePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if !super::require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::CREATE_INVITE,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing CREATE_INVITE permission",
crypto,
)
.await?;
return Ok(());
}
let inv = state
.storage
.create_invite(
&req.guild_id,
&sess.user_id,
req.max_uses,
req.expires_in_seconds,
)
.await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"INVITE_CREATE",
Some(&inv.id),
Some("invite"),
None,
)
.await?;
let info = InviteInfo {
id: inv.id,
guild_id: inv.guild_id,
guild_name: inv.guild_name,
code: inv.code,
creator_id: inv.creator_id,
max_uses: inv.max_uses,
uses: inv.uses,
expires_at: inv.expires_at,
created_at: inv.created_at,
};
io::send_encrypted(
stream,
PacketId::InviteCreate,
seq,
&to_payload(&info),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_invite_accept(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: InviteAcceptPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let inv = state
.storage
.get_invite_by_code(&req.code)
.await?
.ok_or_else(|| anyhow::anyhow!("invite not found"))?;
if let Some(exp) = inv.expires_at {
let now = now_ms();
if now > exp {
send_err(
stream,
seq,
crate::proto::ErrorCode::InvalidPacket,
"invite expired",
crypto,
)
.await?;
return Ok(());
}
}
if let Some(max) = inv.max_uses
&& inv.uses >= max
{
send_err(
stream,
seq,
crate::proto::ErrorCode::InvalidPacket,
"invite max uses reached",
crypto,
)
.await?;
return Ok(());
}
state
.storage
.add_guild_member(&inv.guild_id, &sess.user_id)
.await?;
state.storage.use_invite(&inv.id).await?;
state
.storage
.append_audit_log(
&inv.guild_id,
&sess.user_id,
"MEMBER_JOIN_INVITE",
Some(&sess.user_id),
Some("member"),
None,
)
.await?;
io::send_encrypted(
stream,
PacketId::InviteAccept,
seq,
&to_payload(&serde_json::json!({"guild_id": inv.guild_id, "guild_name": inv.guild_name})),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_invite_delete(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: InviteDeletePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if !super::require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::MANAGE_GUILD,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing MANAGE_GUILD permission",
crypto,
)
.await?;
return Ok(());
}
state.storage.delete_invite(&req.invite_id).await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"INVITE_DELETE",
Some(&req.invite_id),
Some("invite"),
None,
)
.await?;
io::send_encrypted(
stream,
PacketId::InviteDelete,
seq,
&to_payload(&serde_json::json!({"invite_id": req.invite_id})),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,61 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{io, state::State},
proto::{GuildInfo, GuildListPayload, PacketId, SessionCrypto, to_payload},
};
pub async fn handle_guild_list(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let rows = state.storage.list_user_guilds(&sess.user_id).await?;
let guilds: Vec<GuildInfo> = rows
.iter()
.map(|g| GuildInfo {
id: g.id.clone(),
owner_id: g.owner_id.clone(),
name: g.name.clone(),
member_count: g.member_count,
created_at: g.created_at,
})
.collect();
for g in &rows {
let roles = state.storage.get_user_roles(&g.id, &sess.user_id).await?;
let color = roles
.iter()
.max_by_key(|r| r.position)
.map(|r| r.color.clone())
.unwrap_or_else(|| "#ffffff".into());
io::send_encrypted(
stream,
PacketId::UserRoleUpdate,
seq,
&to_payload(
&serde_json::json!({"user_id": sess.user_id, "guild_id": g.id, "color": color}),
),
crypto,
)
.await?;
}
io::send_encrypted(
stream,
PacketId::GuildList,
seq,
&to_payload(&GuildListPayload { guilds }),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,154 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::{permissions::Permissions, session},
net::{io, state::State},
proto::{
GuildMemberJoinPayload, GuildMemberKickPayload, GuildMemberLeavePayload, PacketId,
SessionCrypto, to_payload,
},
};
use super::send_err;
pub async fn handle_guild_member_join(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildMemberJoinPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
state
.storage
.add_guild_member(&req.guild_id, &sess.user_id)
.await?;
let roles = state
.storage
.get_user_roles(&req.guild_id, &sess.user_id)
.await?;
let color = roles
.iter()
.max_by_key(|r| r.position)
.map(|r| r.color.clone())
.unwrap_or_else(|| "#ffffff".into());
io::send_encrypted(
stream,
PacketId::GuildMemberJoin,
seq,
&to_payload(&serde_json::json!({"guild_id": req.guild_id, "user_id": sess.user_id})),
crypto,
)
.await?;
io::send_encrypted(
stream,
PacketId::UserRoleUpdate,
seq,
&to_payload(
&serde_json::json!({"user_id": sess.user_id, "guild_id": req.guild_id, "color": color}),
),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_guild_member_leave(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildMemberLeavePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let target = if req.user_id.is_empty() {
&sess.user_id
} else {
&req.user_id
};
state
.storage
.remove_guild_member(&req.guild_id, target)
.await?;
io::send_encrypted(
stream,
PacketId::GuildMemberLeave,
seq,
&to_payload(&serde_json::json!({"guild_id": req.guild_id, "user_id": target})),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_guild_member_kick(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildMemberKickPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if !super::require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::KICK_MEMBERS,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing KICK_MEMBERS permission",
crypto,
)
.await?;
return Ok(());
}
state
.storage
.remove_guild_member(&req.guild_id, &req.user_id)
.await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"MEMBER_KICK",
Some(&req.user_id),
Some("member"),
None,
)
.await?;
io::send_encrypted(
stream,
PacketId::GuildMemberKick,
seq,
&to_payload(&serde_json::json!({"guild_id": req.guild_id, "user_id": req.user_id})),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,230 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::{permissions::Permissions, session},
net::{io, state::State},
proto::{
GuildMemberInfoPayload, GuildMemberListFetchPayload, GuildMemberListPayload,
GuildRoleInfoPayload, GuildRoleListFetchPayload, GuildRoleListPayload, PacketId,
RoleAssignPayload, SessionCrypto, to_payload,
},
};
use super::{require_perm, send_err};
/// Fetch the member list for a guild (visible to all members).
pub async fn handle_member_list_fetch(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildMemberListFetchPayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
// Optional: verify the requester is a member of the guild.
let owner_id = state
.storage
.get_guild(&req.guild_id)
.await?
.map(|g| g.owner_id);
let rows = state.storage.list_guild_members(&req.guild_id).await?;
let members: Vec<GuildMemberInfoPayload> = rows
.into_iter()
.map(|r| GuildMemberInfoPayload {
user_id: r.user_id.clone(),
nickname: r.nickname,
joined_at: r.joined_at,
role_color: r.role_color,
role_name: r.role_name,
is_owner: owner_id.as_deref() == Some(&r.user_id),
})
.collect();
let _ = sess;
let resp = GuildMemberListPayload {
guild_id: req.guild_id,
members,
};
io::send_encrypted(
stream,
PacketId::GuildMemberList,
seq,
&to_payload(&resp),
crypto,
)
.await?;
Ok(())
}
/// Assign a role to a user (admin only: requires MANAGE_ROLES).
pub async fn handle_role_assign(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: RoleAssignPayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if !require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::MANAGE_ROLES,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing MANAGE_ROLES permission",
crypto,
)
.await?;
return Ok(());
}
state
.storage
.assign_role(&req.guild_id, &req.user_id, &req.role_id)
.await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"ROLE_ASSIGN",
Some(&req.user_id),
Some("user"),
Some(&req.role_id),
)
.await?;
// Confirm to caller.
io::send_encrypted(
stream,
PacketId::GuildRoleAssign,
seq,
&to_payload(
&serde_json::json!({"ok": true, "user_id": req.user_id, "role_id": req.role_id}),
),
crypto,
)
.await?;
Ok(())
}
/// Remove a role from a user (admin only: requires MANAGE_ROLES).
pub async fn handle_role_unassign(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: RoleAssignPayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if !require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::MANAGE_ROLES,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing MANAGE_ROLES permission",
crypto,
)
.await?;
return Ok(());
}
state
.storage
.remove_role_from_user(&req.guild_id, &req.user_id, &req.role_id)
.await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"ROLE_UNASSIGN",
Some(&req.user_id),
Some("user"),
Some(&req.role_id),
)
.await?;
io::send_encrypted(
stream,
PacketId::GuildRoleUnassign,
seq,
&to_payload(
&serde_json::json!({"ok": true, "user_id": req.user_id, "role_id": req.role_id}),
),
crypto,
)
.await?;
Ok(())
}
/// Fetch all roles defined in a guild (visible to all members).
pub async fn handle_role_list_fetch(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildRoleListFetchPayload = serde_json::from_slice(payload)?;
let _sess = session::get(&state.sessions, session_id).await;
let rows = state.storage.list_guild_roles(&req.guild_id).await?;
let roles: Vec<GuildRoleInfoPayload> = rows
.into_iter()
.map(|r| GuildRoleInfoPayload {
id: r.id,
guild_id: r.guild_id,
name: r.name,
color: r.color,
permissions: r.permissions as u64,
position: r.position,
})
.collect();
let resp = GuildRoleListPayload {
guild_id: req.guild_id,
roles,
};
io::send_encrypted(
stream,
PacketId::GuildRoleList,
seq,
&to_payload(&resp),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,68 @@
mod audit;
mod crud;
mod invites;
mod list;
mod members;
mod members_list;
mod roles;
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::permissions::Permissions,
net::{io, state::State},
proto::{ErrorPayload, PacketId, SessionCrypto, to_payload},
};
async fn send_err(
stream: &mut TcpStream,
seq: &mut u32,
code: crate::proto::ErrorCode,
msg: &str,
crypto: &SessionCrypto,
) -> Result<()> {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&ErrorPayload {
code: code as u32,
message: msg.into(),
}),
crypto,
)
.await
}
async fn require_perm(
state: &State,
guild_id: &str,
user_id: &str,
required: Permissions,
) -> Result<bool> {
if let Some(g) = state.storage.get_guild(guild_id).await?
&& g.owner_id == user_id
{
return Ok(true);
}
let perms = state.storage.get_user_role_perms(guild_id, user_id).await?;
Ok(Permissions::from_role_perms(&perms).has(required))
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
pub use audit::handle_audit_log_fetch;
pub use crud::{handle_guild_create, handle_guild_delete};
pub use invites::{handle_invite_accept, handle_invite_create, handle_invite_delete};
pub use list::handle_guild_list;
pub use members::{handle_guild_member_join, handle_guild_member_kick, handle_guild_member_leave};
pub use members_list::{
handle_member_list_fetch, handle_role_assign, handle_role_list_fetch, handle_role_unassign,
};
pub use roles::{handle_role_create, handle_role_delete};

View file

@ -0,0 +1,128 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::{permissions::Permissions, session},
net::{io, state::State},
proto::{PacketId, RoleCreatePayload, RoleDeletePayload, SessionCrypto, to_payload},
};
use super::send_err;
pub async fn handle_role_create(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: RoleCreatePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if !super::require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::MANAGE_ROLES,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing MANAGE_ROLES permission",
crypto,
)
.await?;
return Ok(());
}
let color = req.color.as_deref().unwrap_or("#ffffff");
let permissions = req.permissions.unwrap_or(0);
let role_id = state
.storage
.create_role(&req.guild_id, &req.name, color, permissions, 1)
.await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"ROLE_CREATE",
Some(&role_id),
Some("role"),
None,
)
.await?;
io::send_encrypted(
stream,
PacketId::RoleCreate,
seq,
&to_payload(
&serde_json::json!({"id": role_id, "guild_id": req.guild_id, "name": req.name}),
),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_role_delete(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: RoleDeletePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if !super::require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::MANAGE_ROLES,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing MANAGE_ROLES permission",
crypto,
)
.await?;
return Ok(());
}
state.storage.delete_role(&req.role_id).await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"ROLE_DELETE",
Some(&req.role_id),
Some("role"),
None,
)
.await?;
io::send_encrypted(
stream,
PacketId::RoleDelete,
seq,
&to_payload(&serde_json::json!({"role_id": req.role_id})),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,22 @@
pub mod channel;
pub mod content;
pub mod deliver;
pub mod direct_message;
pub mod dispatch;
pub mod friends;
pub mod guild;
pub mod run;
pub use dispatch::dispatch;
pub use run::run_session;
use tokio::net::TcpStream;
use crate::{net::state::State, proto::SessionCrypto};
pub struct Ctx<'a> {
pub stream: &'a mut TcpStream,
pub seq: &'a mut u32,
pub crypto: &'a SessionCrypto,
pub state: &'a State,
}

View file

@ -0,0 +1,55 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tokio::sync::broadcast;
use tracing::warn;
use crate::{
net::{io, state::BroadcastMsg, state::State},
proto::{self, PacketId, SessionCrypto, to_payload},
};
use super::{Ctx, deliver::deliver_encrypted, dispatch};
pub async fn run_session(
stream: &mut TcpStream,
addr: std::net::SocketAddr,
seq: &mut u32,
session_id: &str,
crypto: &SessionCrypto,
state: &State,
bcast_rx: &mut broadcast::Receiver<BroadcastMsg>,
) -> Result<()> {
let mut ctx = Ctx {
stream,
seq,
crypto,
state,
};
loop {
tokio::select! {
result = io::read_encrypted(ctx.stream, ctx.crypto) => {
let (hdr, payload) = result?;
let pid = match PacketId::from_u16(hdr.packet_id) {
Some(p) => p,
None => {
warn!("{addr} unknown 0x{:04X}", hdr.packet_id);
io::send_encrypted(ctx.stream, PacketId::Error, ctx.seq, &to_payload(&proto::ErrorPayload {
code: proto::ErrorCode::InvalidPacket as u32,
message: "unknown packet".into(),
}), ctx.crypto).await?;
continue;
}
};
dispatch(&mut ctx, session_id, pid, &payload, addr).await?;
if pid == PacketId::Disconnect { return Ok(()); }
}
bcast = bcast_rx.recv() => {
match bcast {
Ok(msg) => deliver_encrypted(ctx.stream, ctx.seq, session_id, &msg, ctx.crypto, ctx.state).await?,
Err(broadcast::error::RecvError::Lagged(n)) => warn!("{addr} lagged {n}"),
Err(broadcast::error::RecvError::Closed) => return Ok(()),
}
}
}
}
}

166
gateway/src/lib.rs Normal file
View file

@ -0,0 +1,166 @@
pub mod admin;
pub mod bootstrap;
pub mod domain;
pub mod handler;
pub mod net;
pub mod proto;
use anyhow::Result;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use tokio::net::TcpListener;
use tokio::sync::broadcast;
use tracing::{debug, error, info, warn};
use domain::{channels, config, session, storage};
use net::state::State;
pub async fn run(cfg: Arc<config::Config>) -> Result<()> {
let private_mode = cfg.is_private();
if private_mode {
info!("private mode enabled, federation disabled");
} else {
info!("federation enabled");
}
info!(
"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)");
}
match cfg.storage.backend.as_deref().unwrap_or("sqlite") {
"sqlite" => {
if cfg.storage.postgres_url.is_some() {
warn!("storage.postgres_url is set but backend=sqlite; postgres_url is ignored");
}
}
"postgres" => {
warn!("backend=postgres is not implemented yet; sqlite storage will be used");
if cfg.storage.postgres_url.is_none() {
warn!("backend=postgres configured but storage.postgres_url is missing");
}
}
other => warn!("unknown storage backend '{other}'; sqlite storage will be used"),
}
let sqlite = cfg
.storage
.sqlite_path
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "./dev/data/vnox.db".into());
let server_identity = Arc::new(
bootstrap::server_identity::ServerIdentity::load_or_generate(&cfg.storage.data_dir)?,
);
let server_pubkey = server_identity.pubkey_hex();
info!(
"server identity: {}…",
&server_pubkey[..8.min(server_pubkey.len())]
);
let (tx, _) = broadcast::channel(256);
let metrics = admin::metrics::Metrics::new();
let sessions_count = Arc::new(AtomicUsize::new(0));
let channels_count = Arc::new(AtomicUsize::new(0));
let state = State::new(
session::new_store(),
channels::new_store(),
Arc::new(storage::Storage::connect(&sqlite).await?),
cfg.clone(),
server_identity,
tx,
metrics.clone(),
sessions_count.clone(),
channels_count.clone(),
);
let admin_bind = cfg
.gateway
.admin_bind
.clone()
.unwrap_or_else(|| "0.0.0.0:7601".to_string());
let admin_state = admin::AdminState {
started_at: std::time::Instant::now(),
node_name: cfg.node.name.clone(),
node_address: cfg.node.address.clone(),
private_mode,
metrics: metrics.clone(),
sessions_count,
channels_count,
};
tokio::spawn(admin::run(admin_bind, admin_state));
let listener = TcpListener::bind(&cfg.gateway.bind).await?;
info!("listening on {}", cfg.gateway.bind);
loop {
match listener.accept().await {
Ok((stream, addr)) => {
info!("connection from {addr}");
metrics.inc(&metrics.connections_total);
let s = state.clone();
tokio::spawn(async move {
if let Err(e) = handle(stream, addr, s).await {
debug!("{addr} closed: {e}");
}
});
}
Err(e) => error!("accept: {e}"),
}
}
}
async fn handle(
mut stream: tokio::net::TcpStream,
addr: std::net::SocketAddr,
state: State,
) -> Result<()> {
let mut seq = 0u32;
let (sess, crypto) = net::handshake::run(&mut stream, addr, &state, &mut seq).await?;
let sid = sess.session_id.clone();
state
.sessions_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut bcast_rx = state.broadcast.subscribe();
let result = handler::run_session(
&mut stream,
addr,
&mut seq,
&sid,
&crypto,
&state,
&mut bcast_rx,
)
.await;
if result.is_err() {
let p = proto::DisconnectPayload {
reason: "session closed".into(),
};
let _ = net::io::send_encrypted(
&mut stream,
proto::PacketId::Disconnect,
&mut seq,
&proto::to_payload(&p),
&crypto,
)
.await;
}
if let Some(ch) = session::get(&state.sessions, &sid)
.await
.and_then(|s| s.channel_id)
{
channels::leave(&state.channels, &ch, &sid).await;
handler::channel::broadcast_leave(&state, &ch, &sid).await;
}
session::remove(&state.sessions, &sid).await;
state
.sessions_count
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
state.rate_limiter.remove(&sid);
debug!("{addr} cleaned up");
result
}

12
gateway/src/main.rs Normal file
View file

@ -0,0 +1,12 @@
use anyhow::Result;
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(std::env::var("VNOX_LOG").unwrap_or_else(|_| "info".into()))
.init();
let cfg = Arc::new(vnox_gateway::domain::config::load()?);
vnox_gateway::run(cfg).await
}

View file

@ -0,0 +1,140 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tracing::{debug, info, warn};
use crate::{
domain::{auth, session},
net::io,
net::state::State,
proto::{self, AuthPayload, HelloPayload, PacketId, SessionCrypto, SessionPayload, to_payload},
};
const LNEX_VERSION: &str = "v1";
/// Run HELLO → AUTH → SESSION with X25519 ECDH key exchange.
///
/// Returns the session and the derived crypto context (encryption keys).
/// All subsequent packets must be encrypted with `crypto`.
pub async fn run(
stream: &mut TcpStream,
addr: std::net::SocketAddr,
state: &State,
seq: &mut u32,
) -> Result<(session::Session, SessionCrypto)> {
// Generate ephemeral X25519 keypair for forward secrecy
let (eph_sk, eph_pk) = SessionCrypto::new_ephemeral();
let eph_pk_hex = hex::encode(eph_pk.as_bytes());
// HELLO — include server's ephemeral public key + privacy mode
let challenge = auth::new_challenge();
let private_mode = state.config.is_private();
let hello = HelloPayload {
lnex_version: LNEX_VERSION.into(),
server_pubkey: state.server_identity.pubkey_hex(),
challenge_nonce: hex::encode(challenge),
node_name: state.config.node.name.clone(),
server_eph_pubkey: eph_pk_hex,
private_mode,
};
io::send_packet(stream, PacketId::Hello, seq, &to_payload(&hello)).await?;
debug!("{addr} ← HELLO (eph key included)");
// AUTH — receive client's ephemeral public key
let (hdr, payload) = io::read_packet(stream).await?;
if PacketId::from_u16(hdr.packet_id) != Some(PacketId::Auth) {
io::send_error(
stream,
seq,
proto::ErrorCode::InvalidPacket,
"expected AUTH",
)
.await?;
return Err(anyhow::anyhow!("expected AUTH"));
}
let msg: AuthPayload = serde_json::from_slice(&payload)?;
debug!("{addr} → AUTH nick={}", msg.nickname);
if msg.lnex_version != LNEX_VERSION {
io::send_error(
stream,
seq,
proto::ErrorCode::VersionMismatch,
"unsupported version",
)
.await?;
return Err(anyhow::anyhow!("version mismatch"));
}
let pubkey =
hex_to_32(&msg.client_pubkey).ok_or_else(|| anyhow::anyhow!("bad client pubkey"))?;
let sig = hex_to_64(&msg.signature).ok_or_else(|| anyhow::anyhow!("bad signature"))?;
if let Err(e) = auth::verify_auth(&challenge, &pubkey, &sig) {
warn!("{addr} auth failed: {e}");
state.metrics.inc(&state.metrics.auth_failures);
io::send_error(
stream,
seq,
proto::ErrorCode::AuthFailed,
"invalid signature",
)
.await?;
return Err(anyhow::anyhow!("auth failed"));
}
if state.storage.is_banned(&msg.client_pubkey).await? {
state.metrics.inc(&state.metrics.auth_failures);
io::send_error(stream, seq, proto::ErrorCode::AuthFailed, "banned").await?;
return Err(anyhow::anyhow!("banned"));
}
state
.storage
.upsert_user(&msg.client_pubkey, &msg.nickname)
.await?;
// ECDH: compute shared secret from server's ephemeral sk + client's ephemeral pk
let client_eph_raw = hex_to_32(&msg.client_eph_pubkey)
.ok_or_else(|| anyhow::anyhow!("bad client eph pubkey"))?;
let client_eph_pk = x25519_dalek::PublicKey::from(client_eph_raw);
let shared_secret = SessionCrypto::ecdh(eph_sk, &client_eph_pk);
// SESSION
let sess = session::create(
&state.sessions,
msg.client_pubkey.clone(),
msg.nickname.clone(),
)
.await;
// Derive encryption keys from shared secret + session_id
let crypto = SessionCrypto::derive(shared_secret.as_bytes(), &sess.session_id);
let timeout_secs = state.config.gateway.session_timeout.unwrap_or(600);
let expires_at = std::time::SystemTime::now()
.checked_add(std::time::Duration::from_secs(timeout_secs))
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let sp = SessionPayload {
session_id: sess.session_id.clone(),
token: sess.token.clone(),
expires_at,
};
io::send_packet(stream, PacketId::Session, seq, &to_payload(&sp)).await?;
info!(
"{addr} auth ok — nick={} sid={} encrypted",
sess.nickname,
&sess.session_id[..8]
);
Ok((sess, crypto))
}
fn hex_to_32(s: &str) -> Option<[u8; 32]> {
hex::decode(s).ok()?.try_into().ok()
}
fn hex_to_64(s: &str) -> Option<[u8; 64]> {
hex::decode(s).ok()?.try_into().ok()
}

135
gateway/src/net/io.rs Normal file
View file

@ -0,0 +1,135 @@
use anyhow::Result;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use crate::proto::{
ErrorCode, ErrorPayload, PacketHeader, PacketId, SessionCrypto, encode_packet, flags,
to_payload,
};
const MAX_PAYLOAD: u32 = 4 * 1024 * 1024;
// ─── Unencrypted IO (used during handshake) ─────────────────────────────────
pub async fn send_packet(
stream: &mut TcpStream,
id: PacketId,
seq: &mut u32,
payload: &[u8],
) -> Result<()> {
let data = encode_packet(id, *seq, payload);
*seq = seq.wrapping_add(1);
stream.write_all(&data).await?;
Ok(())
}
pub async fn send_error(
stream: &mut TcpStream,
seq: &mut u32,
code: ErrorCode,
msg: &str,
) -> Result<()> {
let p = ErrorPayload {
code: code as u32,
message: msg.into(),
};
debug_assert!(ErrorCode::from_u32(p.code).is_some());
send_packet(stream, PacketId::Error, seq, &to_payload(&p)).await
}
pub async fn read_packet(stream: &mut TcpStream) -> Result<(PacketHeader, Vec<u8>)> {
let mut buf = [0u8; PacketHeader::SIZE];
stream.read_exact(&mut buf).await?;
let hdr = PacketHeader::from_bytes(&buf);
if hdr.flags & !flags::KNOWN_MASK != 0 {
return Err(anyhow::anyhow!("unknown packet flags: 0x{:04X}", hdr.flags));
}
if hdr.payload_length > MAX_PAYLOAD {
return Err(anyhow::anyhow!("payload too large: {}", hdr.payload_length));
}
let mut payload = vec![0u8; hdr.payload_length as usize];
if !payload.is_empty() {
stream.read_exact(&mut payload).await?;
}
Ok((hdr, payload))
}
// ─── Encrypted IO (used after handshake) ─────────────────────────────────────
/// Send an encrypted packet (server → client).
/// Sets the ENCRYPTED flag and encrypts the payload with s2c_key.
pub async fn send_encrypted(
stream: &mut TcpStream,
id: PacketId,
seq: &mut u32,
payload: &[u8],
crypto: &SessionCrypto,
) -> Result<()> {
let encrypted = crypto.encrypt_s2c(*seq as u64, payload);
let flags_val = flags::ENCRYPTED;
let mut hdr_buf = [0u8; PacketHeader::SIZE];
hdr_buf[0..2].copy_from_slice(&(id as u16).to_be_bytes());
hdr_buf[2..4].copy_from_slice(&flags_val.to_be_bytes());
hdr_buf[4..8].copy_from_slice(&seq.to_be_bytes());
hdr_buf[8..12].copy_from_slice(&(encrypted.len() as u32).to_be_bytes());
*seq = seq.wrapping_add(1);
let mut out = Vec::with_capacity(PacketHeader::SIZE + encrypted.len());
out.extend_from_slice(&hdr_buf);
out.extend_from_slice(&encrypted);
stream.write_all(&out).await?;
Ok(())
}
/// Read and decrypt a packet (client → server).
/// Verifies the ENCRYPTED flag and decrypts with c2s_key.
pub async fn read_encrypted(
stream: &mut TcpStream,
crypto: &SessionCrypto,
) -> Result<(PacketHeader, Vec<u8>)> {
let mut buf = [0u8; PacketHeader::SIZE];
stream.read_exact(&mut buf).await?;
let hdr = PacketHeader::from_bytes(&buf);
if hdr.flags & !flags::KNOWN_MASK != 0 {
return Err(anyhow::anyhow!("unknown packet flags: 0x{:04X}", hdr.flags));
}
if hdr.flags & flags::ENCRYPTED == 0 {
return Err(anyhow::anyhow!("packet not encrypted"));
}
if hdr.payload_length > MAX_PAYLOAD {
return Err(anyhow::anyhow!("payload too large: {}", hdr.payload_length));
}
let mut encrypted = vec![0u8; hdr.payload_length as usize];
if !encrypted.is_empty() {
stream.read_exact(&mut encrypted).await?;
}
let payload = crypto.decrypt_c2s(hdr.sequence as u64, &encrypted)?;
Ok((hdr, payload))
}
/// Re-encode and deliver a pre-encoded broadcast packet with encryption.
///
/// Extracts the packet ID from the pre-encoded `raw_data`, re-encodes
/// with the correct sequence and encryption for this recipient.
pub async fn deliver_encrypted(
stream: &mut TcpStream,
crypto: &SessionCrypto,
seq: &mut u32,
raw_data: &[u8],
) -> Result<()> {
if raw_data.len() < PacketHeader::SIZE {
return Err(anyhow::anyhow!("deliver: packet too short"));
}
let raw_hdr = PacketHeader::from_bytes(raw_data[..PacketHeader::SIZE].try_into().unwrap());
let pid = match PacketId::from_u16(raw_hdr.packet_id) {
Some(p) => p,
None => {
return Err(anyhow::anyhow!(
"deliver: unknown packet id 0x{:04X}",
raw_hdr.packet_id
));
}
};
let payload = &raw_data[PacketHeader::SIZE..];
send_encrypted(stream, pid, seq, payload, crypto).await
}

3
gateway/src/net/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod handshake;
pub mod io;
pub mod state;

73
gateway/src/net/state.rs Normal file
View file

@ -0,0 +1,73 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use tokio::sync::{RwLock, broadcast};
use crate::admin::metrics::Metrics;
use crate::bootstrap::server_identity::ServerIdentity;
use crate::domain::rate_limit::RateLimiter;
use crate::domain::{
channels::ChannelStore, config::Config, session::SessionStore, storage::Storage,
};
use crate::proto::PresenceInfo;
#[derive(Clone)]
pub struct State {
pub sessions: SessionStore,
pub channels: ChannelStore,
pub storage: Arc<Storage>,
pub config: Arc<Config>,
pub server_identity: Arc<ServerIdentity>,
pub broadcast: broadcast::Sender<BroadcastMsg>,
/// user_id → presence info (Phase 1.2)
pub presences: Arc<RwLock<HashMap<String, PresenceInfo>>>,
/// Prometheus-style counters.
pub metrics: Arc<Metrics>,
/// Live count of authenticated sessions.
pub sessions_count: Arc<AtomicUsize>,
/// Live count of channels with at least one member.
#[allow(dead_code)]
pub channels_count: Arc<AtomicUsize>,
/// Per-session rate limiter (token bucket).
pub rate_limiter: Arc<RateLimiter>,
}
impl State {
#[allow(clippy::too_many_arguments)]
pub fn new(
sessions: SessionStore,
channels: ChannelStore,
storage: Arc<Storage>,
config: Arc<Config>,
server_identity: Arc<ServerIdentity>,
broadcast: broadcast::Sender<BroadcastMsg>,
metrics: Arc<Metrics>,
sessions_count: Arc<AtomicUsize>,
channels_count: Arc<AtomicUsize>,
) -> Self {
let rate_per_sec = config.gateway.message_rate_per_sec.unwrap_or(5.0);
let burst = config.gateway.message_rate_burst.unwrap_or(10);
let rate_limiter = Arc::new(RateLimiter::new(rate_per_sec, burst));
Self {
sessions,
channels,
storage,
config,
server_identity,
broadcast,
presences: Arc::new(RwLock::new(HashMap::new())),
metrics,
sessions_count,
channels_count,
rate_limiter,
}
}
}
#[derive(Clone, Debug)]
pub struct BroadcastMsg {
pub channel_id: Option<String>,
pub exclude_session: Option<String>,
pub target_session_id: Option<String>,
pub data: Vec<u8>,
}

View file

@ -0,0 +1,51 @@
use chacha20poly1305::{
ChaCha20Poly1305, Key, KeyInit, Nonce,
aead::{Aead, Payload},
};
pub(super) fn make_nonce(cid: &[u8; 8], seq: u64) -> [u8; 12] {
let mut nonce = [0u8; 12];
nonce[..8].copy_from_slice(cid);
nonce[8..12].copy_from_slice(&(seq as u32).to_le_bytes());
nonce
}
pub(super) fn encrypt_with_key(
key: &[u8; 32],
cid: &[u8; 8],
seq: u64,
plaintext: &[u8],
) -> Vec<u8> {
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce_bytes = make_nonce(cid, seq);
let nonce = Nonce::from_slice(&nonce_bytes);
cipher
.encrypt(
nonce,
Payload {
msg: plaintext,
aad: &[],
},
)
.expect("ChaCha20-Poly1305 encryption is infallible")
}
pub(super) fn decrypt_with_key(
key: &[u8; 32],
cid: &[u8; 8],
seq: u64,
ciphertext: &[u8],
) -> anyhow::Result<Vec<u8>> {
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce_bytes = make_nonce(cid, seq);
let nonce = Nonce::from_slice(&nonce_bytes);
cipher
.decrypt(
nonce,
Payload {
msg: ciphertext,
aad: &[],
},
)
.map_err(|e| anyhow::anyhow!("decryption failed: {e:?}"))
}

View file

@ -0,0 +1,112 @@
mod cipher;
use hkdf::Hkdf;
use sha2::Sha256;
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
#[derive(Clone)]
pub struct SessionCrypto {
c2s_key: [u8; 32],
s2c_key: [u8; 32],
cid: [u8; 8],
}
impl SessionCrypto {
pub fn derive(shared_secret: &[u8; 32], session_id: &str) -> Self {
let salt = b"VNOX-LNEx-KDF-v1";
let hk = Hkdf::<Sha256>::new(Some(salt), shared_secret);
let mut keys = [0u8; 64];
hk.expand(b"session-keys", &mut keys)
.expect("64 bytes is within HKDF max");
let c2s_key: [u8; 32] = keys[..32].try_into().unwrap();
let s2c_key: [u8; 32] = keys[32..64].try_into().unwrap();
use sha2::Digest;
let hash = Sha256::digest(session_id.as_bytes());
let cid: [u8; 8] = hash[..8].try_into().unwrap();
Self {
c2s_key,
s2c_key,
cid,
}
}
pub fn encrypt_s2c(&self, seq: u64, plaintext: &[u8]) -> Vec<u8> {
cipher::encrypt_with_key(&self.s2c_key, &self.cid, seq, plaintext)
}
pub fn decrypt_c2s(&self, seq: u64, ciphertext: &[u8]) -> anyhow::Result<Vec<u8>> {
cipher::decrypt_with_key(&self.c2s_key, &self.cid, seq, ciphertext)
}
pub fn new_ephemeral() -> (EphemeralSecret, PublicKey) {
let mut rng = rand::thread_rng();
let sk = EphemeralSecret::random_from_rng(&mut rng);
let pk = PublicKey::from(&sk);
(sk, pk)
}
pub fn ecdh(secret: EphemeralSecret, peer_public: &PublicKey) -> SharedSecret {
secret.diffie_hellman(peer_public)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypt_then_decrypt_roundtrip() {
let shared_secret = [0xABu8; 32];
let crypto = SessionCrypto::derive(&shared_secret, "test-session-id");
let plaintext = b"hello encrypted world";
let seq = 42;
let ciphertext = crypto.encrypt_s2c(seq, plaintext);
assert_ne!(ciphertext, plaintext);
assert!(ciphertext.len() > plaintext.len());
}
#[test]
fn different_keys_cannot_decrypt_each_other() {
let shared_secret = [0xABu8; 32];
let crypto = SessionCrypto::derive(&shared_secret, "test-session-id");
let s2c_ct = crypto.encrypt_s2c(0, b"server to client");
let result = crypto.decrypt_c2s(0, &s2c_ct);
assert!(result.is_err());
}
#[test]
fn different_keys_produce_different_ciphertexts() {
let secret_a = [0xAAu8; 32];
let secret_b = [0xBBu8; 32];
let crypto_a = SessionCrypto::derive(&secret_a, "session-a");
let crypto_b = SessionCrypto::derive(&secret_b, "session-b");
let plaintext = b"same plaintext";
let ct_a = crypto_a.encrypt_s2c(0, plaintext);
let ct_b = crypto_b.encrypt_s2c(0, plaintext);
assert_ne!(ct_a, ct_b);
}
#[test]
fn ecdh_derives_same_secret_on_both_sides() {
let mut rng = rand::thread_rng();
let alice_sk = EphemeralSecret::random_from_rng(&mut rng);
let alice_pk = PublicKey::from(&alice_sk);
let bob_sk = EphemeralSecret::random_from_rng(&mut rng);
let bob_pk = PublicKey::from(&bob_sk);
let alice_shared = alice_sk.diffie_hellman(&bob_pk);
let bob_shared = bob_sk.diffie_hellman(&alice_pk);
assert_eq!(alice_shared.as_bytes(), bob_shared.as_bytes());
}
}

View file

@ -0,0 +1,16 @@
use super::packet::{PacketHeader, PacketId};
use serde::Serialize;
/// Encode a packet: header + JSON payload bytes.
pub fn encode_packet(id: PacketId, seq: u32, payload: &[u8]) -> Vec<u8> {
let header = PacketHeader::new(id, seq, payload.len() as u32);
let mut out = Vec::with_capacity(PacketHeader::SIZE + payload.len());
out.extend_from_slice(&header.to_bytes());
out.extend_from_slice(payload);
out
}
/// Serialize a payload struct to JSON bytes.
pub fn to_payload<T: Serialize>(v: &T) -> Vec<u8> {
serde_json::to_vec(v).expect("payload serialization is infallible")
}

11
gateway/src/proto/mod.rs Normal file
View file

@ -0,0 +1,11 @@
mod crypto;
mod framing;
mod packet;
mod payloads;
pub use crypto::SessionCrypto;
// Re-export everything so `crate::proto::X` works as before
pub use framing::{encode_packet, to_payload};
pub use packet::{ErrorCode, PacketHeader, PacketId, flags};
pub use payloads::*;

View file

@ -0,0 +1,6 @@
pub const COMPRESSED: u16 = 1 << 0;
pub const ENCRYPTED: u16 = 1 << 1;
pub const FRAGMENTED: u16 = 1 << 2;
pub const LAST_FRAG: u16 = 1 << 3;
pub const ACK_REQ: u16 = 1 << 4;
pub const KNOWN_MASK: u16 = COMPRESSED | ENCRYPTED | FRAGMENTED | LAST_FRAG | ACK_REQ;

View file

@ -0,0 +1,172 @@
#[repr(u16)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PacketId {
Hello = 0x0001,
Auth = 0x0002,
Session = 0x0003,
Ping = 0x0004,
Pong = 0x0005,
VoicePacket = 0x0010,
VoiceState = 0x0011,
ChatMessage = 0x0020,
ChatHistory = 0x0021,
JoinChannel = 0x0030,
LeaveChannel = 0x0031,
ChannelState = 0x0032,
ChannelCreate = 0x0033,
ChannelDelete = 0x0034,
ChannelList = 0x0035,
UserJoin = 0x0040,
UserLeave = 0x0041,
PermissionCheck = 0x0050,
PermissionDeny = 0x0051,
DmStart = 0x0060,
DmMessage = 0x0061,
DmHistory = 0x0062,
DmReadAck = 0x0063,
ReadReceipt = 0x0064,
ReadReceiptBroadcast = 0x0065,
MessageReactionAdd = 0x0066,
MessageReactionRemove = 0x0067,
MessageEdit = 0x0068,
MessageDelete = 0x0069,
TypingStart = 0x0070,
GuildCreate = 0x0100,
GuildDelete = 0x0101,
GuildList = 0x0102,
GuildMemberJoin = 0x0103,
GuildMemberLeave = 0x0104,
GuildMemberKick = 0x0105,
RoleCreate = 0x0106,
RoleDelete = 0x0107,
InviteCreate = 0x0108,
InviteAccept = 0x0109,
InviteDelete = 0x010A,
UserRoleUpdate = 0x010B,
GuildAuditLogFetch = 0x010C,
GuildAuditLog = 0x010D,
GuildMemberListFetch = 0x010E,
GuildMemberList = 0x010F,
GuildRoleAssign = 0x0110,
GuildRoleUnassign = 0x0111,
GuildRoleListFetch = 0x0112,
GuildRoleList = 0x0113,
PresenceUpdate = 0x0140,
PresenceSync = 0x0141,
PresenceEvent = 0x0142,
FriendRequest = 0x0150,
FriendAccept = 0x0151,
FriendDecline = 0x0152,
FriendRemove = 0x0153,
FriendList = 0x0154,
BlockUser = 0x0155,
UnblockUser = 0x0156,
BlockList = 0x0157,
Error = 0x00F0,
Disconnect = 0x00FF,
}
impl PacketId {
pub fn from_u16(v: u16) -> Option<Self> {
match v {
0x0001 => Some(Self::Hello),
0x0002 => Some(Self::Auth),
0x0003 => Some(Self::Session),
0x0004 => Some(Self::Ping),
0x0005 => Some(Self::Pong),
0x0010 => Some(Self::VoicePacket),
0x0011 => Some(Self::VoiceState),
0x0020 => Some(Self::ChatMessage),
0x0021 => Some(Self::ChatHistory),
0x0030 => Some(Self::JoinChannel),
0x0031 => Some(Self::LeaveChannel),
0x0032 => Some(Self::ChannelState),
0x0033 => Some(Self::ChannelCreate),
0x0034 => Some(Self::ChannelDelete),
0x0035 => Some(Self::ChannelList),
0x0040 => Some(Self::UserJoin),
0x0041 => Some(Self::UserLeave),
0x0050 => Some(Self::PermissionCheck),
0x0051 => Some(Self::PermissionDeny),
0x0060 => Some(Self::DmStart),
0x0061 => Some(Self::DmMessage),
0x0062 => Some(Self::DmHistory),
0x0063 => Some(Self::DmReadAck),
0x0064 => Some(Self::ReadReceipt),
0x0065 => Some(Self::ReadReceiptBroadcast),
0x0066 => Some(Self::MessageReactionAdd),
0x0067 => Some(Self::MessageReactionRemove),
0x0068 => Some(Self::MessageEdit),
0x0069 => Some(Self::MessageDelete),
0x0070 => Some(Self::TypingStart),
0x0100 => Some(Self::GuildCreate),
0x0101 => Some(Self::GuildDelete),
0x0102 => Some(Self::GuildList),
0x0103 => Some(Self::GuildMemberJoin),
0x0104 => Some(Self::GuildMemberLeave),
0x0105 => Some(Self::GuildMemberKick),
0x0106 => Some(Self::RoleCreate),
0x0107 => Some(Self::RoleDelete),
0x0108 => Some(Self::InviteCreate),
0x0109 => Some(Self::InviteAccept),
0x010A => Some(Self::InviteDelete),
0x010B => Some(Self::UserRoleUpdate),
0x010C => Some(Self::GuildAuditLogFetch),
0x010D => Some(Self::GuildAuditLog),
0x010E => Some(Self::GuildMemberListFetch),
0x010F => Some(Self::GuildMemberList),
0x0110 => Some(Self::GuildRoleAssign),
0x0111 => Some(Self::GuildRoleUnassign),
0x0112 => Some(Self::GuildRoleListFetch),
0x0113 => Some(Self::GuildRoleList),
0x0140 => Some(Self::PresenceUpdate),
0x0141 => Some(Self::PresenceSync),
0x0142 => Some(Self::PresenceEvent),
0x0150 => Some(Self::FriendRequest),
0x0151 => Some(Self::FriendAccept),
0x0152 => Some(Self::FriendDecline),
0x0153 => Some(Self::FriendRemove),
0x0154 => Some(Self::FriendList),
0x0155 => Some(Self::BlockUser),
0x0156 => Some(Self::UnblockUser),
0x0157 => Some(Self::BlockList),
0x00F0 => Some(Self::Error),
0x00FF => Some(Self::Disconnect),
_ => None,
}
}
}
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
VersionMismatch = 0x01,
AuthFailed = 0x02,
SessionExpired = 0x03,
PermissionDenied = 0x04,
ChannelNotFound = 0x05,
RateLimited = 0x06,
InvalidPacket = 0x07,
NodeUnavailable = 0x08,
GuildNotFound = 0x09,
Blocked = 0x0A,
Internal = 0xFF,
}
impl ErrorCode {
pub fn from_u32(v: u32) -> Option<Self> {
match v {
0x01 => Some(Self::VersionMismatch),
0x02 => Some(Self::AuthFailed),
0x03 => Some(Self::SessionExpired),
0x04 => Some(Self::PermissionDenied),
0x05 => Some(Self::ChannelNotFound),
0x06 => Some(Self::RateLimited),
0x07 => Some(Self::InvalidPacket),
0x08 => Some(Self::NodeUnavailable),
0x09 => Some(Self::GuildNotFound),
0x0A => Some(Self::Blocked),
0xFF => Some(Self::Internal),
_ => None,
}
}
}

View file

@ -0,0 +1,43 @@
pub mod flags;
mod id;
pub use id::{ErrorCode, PacketId};
#[derive(Debug, Clone)]
pub struct PacketHeader {
pub packet_id: u16,
pub flags: u16,
pub sequence: u32,
pub payload_length: u32,
}
impl PacketHeader {
pub const SIZE: usize = 12;
pub fn new(packet_id: PacketId, sequence: u32, payload_length: u32) -> Self {
Self {
packet_id: packet_id as u16,
flags: 0,
sequence,
payload_length,
}
}
pub fn to_bytes(&self) -> [u8; Self::SIZE] {
let mut buf = [0u8; Self::SIZE];
buf[0..2].copy_from_slice(&self.packet_id.to_be_bytes());
buf[2..4].copy_from_slice(&self.flags.to_be_bytes());
buf[4..8].copy_from_slice(&self.sequence.to_be_bytes());
buf[8..12].copy_from_slice(&self.payload_length.to_be_bytes());
buf
}
pub fn from_bytes(buf: &[u8; Self::SIZE]) -> Self {
Self {
packet_id: u16::from_be_bytes([buf[0], buf[1]]),
flags: u16::from_be_bytes([buf[2], buf[3]]),
sequence: u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]),
payload_length: u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]),
}
}
}

View file

@ -0,0 +1,157 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct HelloPayload {
pub lnex_version: String,
pub server_pubkey: String,
pub challenge_nonce: String,
pub node_name: String,
pub server_eph_pubkey: String,
pub private_mode: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthPayload {
pub client_pubkey: String,
pub nickname: String,
pub lnex_version: String,
pub signature: String,
pub client_eph_pubkey: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionPayload {
pub session_id: String,
pub token: String,
pub expires_at: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PingPayload {
pub timestamp: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PongPayload {
pub timestamp: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct JoinChannelPayload {
pub channel_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LeaveChannelPayload {
pub channel_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChannelStatePayload {
pub channel_id: String,
pub channel_name: String,
pub kind: String,
pub members: Vec<MemberInfo>,
pub voice_endpoint: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChannelCreatePayload {
pub channel_id: String,
pub channel_name: String,
/// "text" or "voice".
pub kind: String,
/// Optional guild_id this channel belongs to (Phase 1.x — unused, future).
#[serde(default)]
pub guild_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChannelDeletePayload {
pub channel_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChannelListPayload {
pub channels: Vec<ChannelListItem>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChannelListItem {
pub channel_id: String,
pub channel_name: String,
pub kind: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MemberInfo {
pub user_id: String,
pub nickname: String,
pub in_voice: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UserJoinPayload {
pub channel_id: String,
pub user_id: String,
pub nickname: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UserLeavePayload {
pub channel_id: String,
pub user_id: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChatMessagePayload {
pub message_id: String,
pub channel_id: String,
pub sender_id: String,
pub content: String,
pub timestamp: i64,
#[serde(default)]
pub edited: bool,
/// Optional message_id this message is replying to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_to: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ReactionPayload {
pub message_id: String,
pub channel_id: String,
pub emoji: String,
#[serde(default)]
pub user_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MessageEditPayload {
pub message_id: String,
pub channel_id: String,
pub content: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MessageDeletePayload {
pub message_id: String,
pub channel_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChatHistoryPayload {
pub channel_id: String,
pub messages: Vec<ChatMessagePayload>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorPayload {
pub code: u32,
pub message: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DisconnectPayload {
pub reason: String,
}

View file

@ -0,0 +1,161 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct GuildCreatePayload {
pub name: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GuildInfo {
pub id: String,
pub owner_id: String,
pub name: String,
pub member_count: i64,
pub created_at: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GuildListPayload {
pub guilds: Vec<GuildInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GuildMemberJoinPayload {
pub guild_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GuildMemberLeavePayload {
pub guild_id: String,
pub user_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GuildMemberKickPayload {
pub guild_id: String,
pub user_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RoleCreatePayload {
pub guild_id: String,
pub name: String,
pub color: Option<String>,
pub permissions: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RoleDeletePayload {
pub guild_id: String,
pub role_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InviteCreatePayload {
pub guild_id: String,
pub max_uses: Option<i64>,
pub expires_in_seconds: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct InviteInfo {
pub id: String,
pub guild_id: String,
pub guild_name: String,
pub code: String,
pub creator_id: String,
pub max_uses: Option<i64>,
pub uses: i64,
pub expires_at: Option<i64>,
pub created_at: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InviteAcceptPayload {
pub code: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InviteDeletePayload {
pub guild_id: String,
pub invite_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GuildAuditLogFetchPayload {
pub guild_id: String,
#[serde(default = "default_audit_limit")]
pub limit: i64,
}
fn default_audit_limit() -> i64 {
50
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuditLogEntryPayload {
pub id: String,
pub guild_id: String,
pub actor_id: String,
pub action: String,
pub target_id: Option<String>,
pub target_type: Option<String>,
pub reason: Option<String>,
pub created_at: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GuildAuditLogPayload {
pub guild_id: String,
pub entries: Vec<AuditLogEntryPayload>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GuildMemberListFetchPayload {
pub guild_id: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GuildMemberInfoPayload {
pub user_id: String,
pub nickname: String,
pub joined_at: i64,
pub role_color: String,
pub role_name: String,
/// True if this user is the guild owner.
pub is_owner: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GuildMemberListPayload {
pub guild_id: String,
pub members: Vec<GuildMemberInfoPayload>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RoleAssignPayload {
pub guild_id: String,
pub user_id: String,
pub role_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GuildRoleListFetchPayload {
pub guild_id: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GuildRoleInfoPayload {
pub id: String,
pub guild_id: String,
pub name: String,
pub color: String,
pub permissions: u64,
pub position: i32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GuildRoleListPayload {
pub guild_id: String,
pub roles: Vec<GuildRoleInfoPayload>,
}

View file

@ -0,0 +1,7 @@
mod channel;
mod guild;
mod social;
pub use channel::*;
pub use guild::*;
pub use social::*;

View file

@ -0,0 +1,132 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct DmStartPayload {
pub target_user_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DmStartResponsePayload {
pub dm_id: String,
pub other_user_id: String,
pub other_nickname: String,
pub messages: Vec<DmMessagePayload>,
#[serde(default)]
pub unread_count: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DmMessagePayload {
pub dm_id: String,
pub sender_id: String,
pub content: String,
pub timestamp: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DmHistoryPayload {
pub dm_id: String,
#[serde(default)]
pub messages: Vec<DmMessagePayload>,
#[serde(default)]
pub search_query: Option<String>,
#[serde(default)]
pub limit: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FriendRequestPayload {
pub to_user_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FriendAcceptPayload {
pub from_user_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FriendDeclinePayload {
pub from_user_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FriendRemovePayload {
pub user_id: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FriendInfo {
pub user_id: String,
pub nickname: String,
pub status: String,
pub since: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FriendListPayload {
pub friends: Vec<FriendInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BlockUserPayload {
pub user_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UnblockUserPayload {
pub user_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BlockListPayload {
pub blocked: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FriendEventPayload {
pub event: String,
pub user_id: String,
pub nickname: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PresenceUpdatePayload {
pub status: String,
pub activity_type: Option<String>,
pub activity_text: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PresenceInfo {
pub user_id: String,
pub nickname: String,
pub status: String,
pub activity_type: Option<String>,
pub activity_text: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PresenceSyncPayload {
pub presences: Vec<PresenceInfo>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PresenceEventPayload {
pub user_id: String,
pub nickname: String,
pub status: String,
pub activity_type: Option<String>,
pub activity_text: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ReadReceiptPayload {
pub channel_id: String,
pub last_read_message_id: String,
pub user_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TypingStartPayload {
pub channel_id: String,
}

23
serverd/Cargo.toml Normal file
View file

@ -0,0 +1,23 @@
[package]
name = "vnox-serverd"
description = "VNOX unified server — gateway TCP + voice UDP in one binary"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[[bin]]
name = "vnox-serverd"
path = "src/main.rs"
[dependencies]
vnox-gateway = { path = "../gateway" }
vnox-voice-node = { path = "../voice-node" }
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
toml.workspace = true
serde.workspace = true

49
serverd/src/main.rs Normal file
View file

@ -0,0 +1,49 @@
use anyhow::Result;
use std::sync::Arc;
use tracing::info;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(std::env::var("VNOX_LOG").unwrap_or_else(|_| "info".into()))
.init();
let config_path = std::env::args()
.skip_while(|a| a != "--config")
.nth(1)
.unwrap_or_else(|| "/etc/vnox/config.toml".into());
let text = std::fs::read_to_string(&config_path)?;
let cfg: vnox_gateway::domain::config::Config =
toml::from_str(&text).map_err(|e| anyhow::anyhow!("invalid config: {e}"))?;
info!(
"VNOX Server starting — node: {}",
cfg.node.name
);
info!("Gateway TCP: {}", cfg.gateway.bind);
info!("Voice UDP: {}", cfg.voice.bind);
let node_name = cfg.node.name.clone();
let voice_bind = cfg.voice.bind.clone();
let gate_cfg = Arc::new(cfg);
let voice_handle = tokio::spawn(async move {
vnox_voice_node::runner::run_bind(&node_name, &voice_bind).await
});
let gate_handle = tokio::spawn(async move {
vnox_gateway::run(gate_cfg).await
});
tokio::select! {
r = voice_handle => {
info!("voice node exited: {:?}", r);
}
r = gate_handle => {
info!("gateway exited: {:?}", r);
}
}
Ok(())
}

26
voice-node/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "vnox-voice-node"
description = "VNOX voice node — UDP relay, Opus, jitter buffer"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[lib]
name = "vnox_voice_node"
path = "src/lib.rs"
[[bin]]
name = "vnox-voice-node"
path = "src/main.rs"
[dependencies]
tokio.workspace = true
opus.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
thiserror.workspace = true
toml.workspace = true
serde.workspace = true

View file

@ -0,0 +1,75 @@
use tracing::info;
use super::JitterBuffer;
pub const MIN_TARGET_MS: u32 = 20;
pub const MAX_TARGET_MS: u32 = 150;
pub const JITTER_WINDOW: usize = 64;
const LOSS_INCREASE_THRESH: u32 = 5;
const LOSS_DECREASE_THRESH: u32 = 1;
const STABLE_WINDOWS_BEFORE_DECREASE: u32 = 10;
pub const LOSS_WINDOW_PACKETS: u32 = 50;
const ADJUST_STEP_MS: u32 = 5;
impl JitterBuffer {
pub(super) fn observed_jitter_ms(&self) -> u32 {
if self.arrival_count < 2 {
return 0;
}
let mut deltas = Vec::with_capacity(self.arrival_count - 1);
for i in 0..self.arrival_count - 1 {
let idx0 = (self.arrival_idx + JITTER_WINDOW - self.arrival_count + i) % JITTER_WINDOW;
let idx1 =
(self.arrival_idx + JITTER_WINDOW - self.arrival_count + i + 1) % JITTER_WINDOW;
let delta = self.arrival_times[idx1].saturating_sub(self.arrival_times[idx0]);
deltas.push(delta);
}
if deltas.is_empty() {
return 0;
}
deltas.sort_unstable();
let p90_idx = ((deltas.len() as f64) * 0.90).ceil() as usize - 1;
let p90_idx = p90_idx.min(deltas.len() - 1);
deltas[p90_idx] as u32
}
pub(super) fn adapt_target(&mut self) {
let loss_pct = if self.total_expected > 0 {
(self.loss_count as f64 / self.total_expected as f64 * 100.0) as u32
} else {
0
};
let jitter_ms = self.observed_jitter_ms();
let jitter_based_target = (jitter_ms * 2).clamp(MIN_TARGET_MS, MAX_TARGET_MS);
if loss_pct >= LOSS_INCREASE_THRESH {
let new_target = (self.target_ms + ADJUST_STEP_MS).min(MAX_TARGET_MS);
if new_target != self.target_ms {
info!(
target_ms = new_target,
loss_pct, jitter_ms, "adaptive: increasing target due to packet loss"
);
self.target_ms = new_target;
}
self.stable_windows = 0;
} else if loss_pct <= LOSS_DECREASE_THRESH {
self.stable_windows += 1;
if self.stable_windows >= STABLE_WINDOWS_BEFORE_DECREASE {
let candidate = self.target_ms.saturating_sub(ADJUST_STEP_MS);
let new_target = candidate.max(jitter_based_target).max(MIN_TARGET_MS);
if new_target != self.target_ms {
info!(
target_ms = new_target,
loss_pct, jitter_ms, "adaptive: decreasing target, link stable"
);
self.target_ms = new_target;
}
self.stable_windows = 0;
}
} else {
self.stable_windows = 0;
}
self.loss_count = 0;
self.total_expected = 0;
}
}

View file

@ -0,0 +1,100 @@
use std::collections::BTreeMap;
pub mod adaptive;
pub mod relay;
pub use adaptive::{JITTER_WINDOW, LOSS_WINDOW_PACKETS, MAX_TARGET_MS, MIN_TARGET_MS};
pub struct JitterBuffer {
target_ms: u32,
adaptive: bool,
packets: BTreeMap<u32, BufferedPacket>,
last_played: Option<u32>,
arrival_times: Vec<u64>,
arrival_idx: usize,
arrival_count: usize,
loss_count: u32,
total_expected: u32,
stable_windows: u32,
}
impl JitterBuffer {
pub fn new(target_ms: u32, adaptive: bool) -> Self {
Self {
target_ms,
adaptive,
packets: BTreeMap::new(),
last_played: None,
arrival_times: vec![0u64; JITTER_WINDOW],
arrival_idx: 0,
arrival_count: 0,
loss_count: 0,
total_expected: 0,
stable_windows: 0,
}
}
pub fn push(&mut self, pkt: BufferedPacket) {
let arrived = pkt.arrived_at;
self.arrival_times[self.arrival_idx] = arrived;
self.arrival_idx = (self.arrival_idx + 1) % JITTER_WINDOW;
if self.arrival_count < JITTER_WINDOW {
self.arrival_count += 1;
}
let gap = self
.packets
.keys()
.next_back()
.map(|&highest| {
if pkt.voice_seq > highest {
pkt.voice_seq.wrapping_sub(highest).saturating_sub(1)
} else {
0
}
})
.unwrap_or(0);
if gap > 0 {
self.loss_count += gap;
}
self.total_expected += 1 + gap;
self.packets.insert(pkt.voice_seq, pkt);
if self.adaptive && self.total_expected >= LOSS_WINDOW_PACKETS {
self.adapt_target();
}
}
pub fn len(&self) -> usize {
self.packets.len()
}
pub fn is_empty(&self) -> bool {
self.packets.is_empty()
}
pub fn set_target_ms(&mut self, target_ms: u32) {
self.target_ms = target_ms.clamp(MIN_TARGET_MS, MAX_TARGET_MS);
}
pub fn target_ms(&self) -> u32 {
self.target_ms
}
pub fn is_adaptive(&self) -> bool {
self.adaptive
}
}
#[derive(Debug)]
pub struct BufferedPacket {
pub voice_seq: u32,
pub timestamp: u32,
pub channel_id: u64,
pub opus_data: Vec<u8>,
pub arrived_at: u64,
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,44 @@
use super::{BufferedPacket, JitterBuffer};
impl JitterBuffer {
pub fn pop_ready(&mut self, now_ms: u64) -> Option<BufferedPacket> {
if self.adaptive {
return self.pop_ready_adaptive(now_ms);
}
self.pop_ready_fixed(now_ms)
}
pub fn has_gap(&self) -> bool {
match self.last_played {
None => false,
Some(last) => {
let expected = last.wrapping_add(1);
!self.packets.contains_key(&expected) && !self.packets.is_empty()
}
}
}
fn pop_ready_fixed(&mut self, now_ms: u64) -> Option<BufferedPacket> {
let seq = *self.packets.keys().next()?;
let pkt = self.packets.get(&seq)?;
let buffered_for = now_ms.saturating_sub(pkt.arrived_at);
if buffered_for >= self.target_ms as u64 {
let pkt = self.packets.remove(&seq)?;
self.last_played = Some(seq);
return Some(pkt);
}
None
}
fn pop_ready_adaptive(&mut self, now_ms: u64) -> Option<BufferedPacket> {
let seq = *self.packets.keys().next()?;
let pkt = self.packets.get(&seq)?;
let buffered_for = now_ms.saturating_sub(pkt.arrived_at);
if buffered_for >= self.target_ms as u64 {
let pkt = self.packets.remove(&seq)?;
self.last_played = Some(seq);
return Some(pkt);
}
None
}
}

View file

@ -0,0 +1,103 @@
use super::*;
fn pkt(seq: u32, arrived_at: u64) -> BufferedPacket {
BufferedPacket {
voice_seq: seq,
timestamp: 0,
channel_id: 1,
opus_data: vec![seq as u8],
arrived_at,
}
}
#[test]
fn pop_ready_waits_for_target_ms() {
let mut jb = JitterBuffer::new(20, false);
jb.push(pkt(1, 100));
assert!(jb.pop_ready(110).is_none());
assert_eq!(jb.pop_ready(120).unwrap().voice_seq, 1);
assert!(jb.pop_ready(120).is_none());
}
#[test]
fn pop_ready_releases_in_sequence_order() {
let mut jb = JitterBuffer::new(0, false);
jb.push(pkt(2, 0));
jb.push(pkt(1, 0));
assert_eq!(jb.pop_ready(0).unwrap().voice_seq, 1);
assert_eq!(jb.pop_ready(0).unwrap().voice_seq, 2);
}
#[test]
fn has_gap_after_played_seq() {
let mut jb = JitterBuffer::new(0, false);
jb.push(pkt(1, 0));
jb.pop_ready(0);
jb.push(pkt(3, 0));
assert!(jb.has_gap());
}
#[test]
fn no_gap_when_next_seq_present() {
let mut jb = JitterBuffer::new(0, false);
jb.push(pkt(1, 0));
jb.pop_ready(0);
jb.push(pkt(2, 0));
assert!(!jb.has_gap());
}
#[test]
fn adaptive_mode_increases_on_loss() {
let mut jb = JitterBuffer::new(40, true);
jb.push(pkt(1, 0));
jb.push(pkt(3, 1));
for i in 4..=50 {
jb.push(pkt(i, i as u64));
}
assert!(jb.target_ms <= MAX_TARGET_MS);
}
#[test]
fn set_target_ms_clamps() {
let mut jb = JitterBuffer::new(40, false);
jb.set_target_ms(0);
assert_eq!(jb.target_ms, MIN_TARGET_MS);
jb.set_target_ms(500);
assert_eq!(jb.target_ms, MAX_TARGET_MS);
}
#[test]
fn adaptive_mode_respects_flag() {
let mut jb = JitterBuffer::new(40, false);
jb.push(pkt(1, 0));
for i in 2..=100 {
jb.push(pkt(i, i as u64));
}
assert_eq!(jb.target_ms, 40);
}
#[test]
fn observed_jitter_smooth_timeline() {
let mut jb = JitterBuffer::new(40, true);
for i in 0..JITTER_WINDOW {
jb.push(pkt(i as u32, (i as u64) * 10));
}
let jitter = jb.observed_jitter_ms();
assert!(
(8..=12).contains(&jitter),
"jitter = {jitter} (expected ~10)"
);
}
#[test]
fn observed_jitter_spike() {
let mut jb = JitterBuffer::new(40, true);
let times = [
0, 10, 20, 70, 80, 90, 140, 150, 160, 210, 220, 230, 280, 290, 300,
];
for (i, &t) in times.iter().enumerate() {
jb.push(pkt(i as u32, t));
}
let jitter = jb.observed_jitter_ms();
assert!(jitter >= 40, "jitter = {jitter} (expected >= 40)");
}

67
voice-node/src/lib.rs Normal file
View file

@ -0,0 +1,67 @@
pub mod jitter;
pub mod relay;
pub mod runner;
use anyhow::Result;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Config {
pub node: NodeConfig,
pub voice: VoiceConfig,
}
#[derive(Debug, Deserialize)]
pub struct NodeConfig {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct VoiceConfig {
pub bind: String,
}
pub use runner::run_bind;
pub fn load_config() -> Result<Config> {
let path = std::env::args()
.skip_while(|a| a != "--config")
.nth(1)
.unwrap_or_else(|| "/etc/vnox/config.toml".into());
let text = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("cannot read config {path}: {e}"))?;
toml::from_str(&text).map_err(|e| anyhow::anyhow!("invalid config: {e}"))
}
// ─── Voice packet header ──────────────────────────────────────────────────────
pub const VOICE_HDR_SIZE: usize = 20;
pub const VOICE_PACKET_ID: u16 = 0x0010;
pub const MAX_UDP_PACKET: usize = 1472;
pub const PLAYOUT_INTERVAL_MS: u64 = 5;
pub struct VoiceHeader {
pub packet_id: u16,
pub _flags: u16,
pub voice_seq: u32,
pub timestamp: u32,
pub channel_id: u64,
}
impl VoiceHeader {
pub fn parse(buf: &[u8]) -> Option<Self> {
if buf.len() < VOICE_HDR_SIZE {
return None;
}
Some(Self {
packet_id: u16::from_be_bytes([buf[0], buf[1]]),
_flags: u16::from_be_bytes([buf[2], buf[3]]),
voice_seq: u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]),
timestamp: u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]),
channel_id: u64::from_be_bytes([
buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19],
]),
})
}
}

12
voice-node/src/main.rs Normal file
View file

@ -0,0 +1,12 @@
use anyhow::Result;
use vnox_voice_node::{load_config, runner};
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(std::env::var("VNOX_LOG").unwrap_or_else(|_| "info".into()))
.init();
let config = load_config()?;
runner::run(config).await
}

97
voice-node/src/relay.rs Normal file
View file

@ -0,0 +1,97 @@
use crate::jitter::{BufferedPacket, JitterBuffer};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
use tokio::sync::RwLock;
use tracing::debug;
/// Per-channel state: members, jitter buffer, and sender tracking.
pub struct ChannelState {
/// member address → last packet time
pub members: HashMap<SocketAddr, Instant>,
/// reorders and smooths voice packets
pub jitter: JitterBuffer,
/// voice_seq → original sender address (for relay after pop)
pub senders: HashMap<u32, SocketAddr>,
}
/// Maps channel_id → per-channel state.
pub type ChannelMap = Arc<RwLock<HashMap<u64, ChannelState>>>;
pub fn new_channel_map() -> ChannelMap {
Arc::new(RwLock::new(HashMap::new()))
}
/// Drop members with no packets for longer than `max_idle`.
pub async fn cleanup_stale(channels: &ChannelMap, max_idle: Duration) {
let cutoff = Instant::now() - max_idle;
let mut lock = channels.write().await;
lock.retain(|_, state| {
state.members.retain(|_, last_seen| *last_seen >= cutoff);
!state.members.is_empty()
});
}
/// Push a raw voice packet into the jitter buffer for `channel_id`.
pub async fn push_packet(
channels: &ChannelMap,
channel_id: u64,
sender: SocketAddr,
voice_seq: u32,
timestamp: u32,
raw_data: &[u8],
arrived_at: u64,
) {
let pkt = BufferedPacket {
voice_seq,
timestamp,
channel_id,
opus_data: raw_data.to_vec(),
arrived_at,
};
let mut lock = channels.write().await;
let state = lock.entry(channel_id).or_insert_with(|| ChannelState {
members: HashMap::new(),
jitter: JitterBuffer::new(40, true),
senders: HashMap::new(),
});
state.jitter.push(pkt);
state.senders.insert(voice_seq, sender);
}
/// Pop ready packets from every channel's jitter buffer and relay them.
pub async fn pop_and_relay(socket: &UdpSocket, channels: &ChannelMap, now_ms: u64) {
let mut lock = channels.write().await;
for (_channel_id, state) in lock.iter_mut() {
while let Some(pkt) = state.jitter.pop_ready(now_ms) {
let sender = state.senders.remove(&pkt.voice_seq).unwrap_or_else(|| {
tracing::warn!("sender not found for seq={}", pkt.voice_seq);
SocketAddr::from(([0, 0, 0, 0], 0))
});
for &addr in state.members.keys() {
if addr != sender
&& let Err(e) = socket.send_to(&pkt.opus_data, addr).await
{
debug!("relay send error to {addr}: {e}");
}
}
}
}
}
/// Register (or refresh) a sender in the channel member set.
pub async fn touch_member(channels: &ChannelMap, channel_id: u64, addr: SocketAddr) {
channels
.write()
.await
.entry(channel_id)
.or_insert_with(|| ChannelState {
members: HashMap::new(),
jitter: JitterBuffer::new(40, true),
senders: HashMap::new(),
})
.members
.insert(addr, Instant::now());
}

97
voice-node/src/runner.rs Normal file
View file

@ -0,0 +1,97 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
use tracing::{debug, error, info, warn};
use crate::relay;
use crate::{
Config, MAX_UDP_PACKET, PLAYOUT_INTERVAL_MS, VOICE_HDR_SIZE, VOICE_PACKET_ID, VoiceHeader,
};
pub async fn run(config: Config) -> anyhow::Result<()> {
run_bind(&config.node.name, &config.voice.bind).await
}
pub async fn run_bind(node_name: &str, bind: &str) -> anyhow::Result<()> {
info!("VNOX Voice Node starting — node: {node_name}");
info!("UDP bind: {bind}");
let socket = UdpSocket::bind(bind).await?;
info!("voice node listening on {bind}");
let channels = relay::new_channel_map();
let channels_cleanup = channels.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(15));
loop {
interval.tick().await;
relay::cleanup_stale(&channels_cleanup, Duration::from_secs(30)).await;
}
});
let socket = Arc::new(socket);
let socket_relay = socket.clone();
let channels_playout = channels.clone();
let epoch = Instant::now();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(PLAYOUT_INTERVAL_MS));
loop {
interval.tick().await;
let now_ms = epoch.elapsed().as_millis() as u64;
relay::pop_and_relay(socket_relay.as_ref(), &channels_playout, now_ms).await;
}
});
let mut buf = vec![0u8; MAX_UDP_PACKET];
loop {
let (len, src) = match socket.recv_from(&mut buf).await {
Ok(r) => r,
Err(e) => {
error!("UDP recv error: {e}");
continue;
}
};
let data = &buf[..len];
let hdr = match VoiceHeader::parse(data) {
Some(h) => h,
None => {
warn!("short packet from {src} ({len} bytes), dropping");
continue;
}
};
if hdr.packet_id != VOICE_PACKET_ID {
debug!(
"non-voice packet 0x{:04X} from {src}, dropping",
hdr.packet_id
);
continue;
}
relay::touch_member(&channels, hdr.channel_id, src).await;
let arrived_at = epoch.elapsed().as_millis() as u64;
debug!(
"voice seq={} ch={} from {src} ({} bytes opus)",
hdr.voice_seq,
hdr.channel_id,
len - VOICE_HDR_SIZE,
);
relay::push_packet(
&channels,
hdr.channel_id,
src,
hdr.voice_seq,
hdr.timestamp,
data,
arrived_at,
)
.await;
}
}