From e751e0bf5ba28c2ecdc9a9129ad3a50c60390c21 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Thu, 9 Jul 2026 11:52:56 +0200 Subject: [PATCH] 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. --- .cargo/config.toml | 29 + .github/.github/ISSUE_TEMPLATE/bug.md | 32 + .github/.github/ISSUE_TEMPLATE/protocol.md | 25 + .github/.github/repo-metadata.md | 68 + .github/.github/workflows/ci.yml | 47 + .gitignore | 4 + Cargo.lock | 2545 +++++++++++++++++ Cargo.toml | 35 + Dockerfile | 25 + Dockerfile.voice-node | 21 + dev/dev/README.md | 82 + dev/dev/config.toml | 25 + docker-compose.yml | 24 + gateway/Cargo.toml | 40 + gateway/src/admin/metrics.rs | 130 + gateway/src/admin/mod.rs | 77 + gateway/src/bootstrap/mod.rs | 1 + gateway/src/bootstrap/server_identity.rs | 58 + gateway/src/domain/auth.rs | 45 + gateway/src/domain/channels/mod.rs | 55 + gateway/src/domain/channels/ops.rs | 64 + gateway/src/domain/config.rs | 118 + gateway/src/domain/mod.rs | 7 + gateway/src/domain/permissions.rs | 33 + gateway/src/domain/rate_limit/mod.rs | 117 + gateway/src/domain/session.rs | 48 + gateway/src/domain/storage/dm_unread.rs | 38 + gateway/src/domain/storage/dms.rs | 127 + gateway/src/domain/storage/guilds/audit.rs | 30 + gateway/src/domain/storage/guilds/mod.rs | 210 ++ gateway/src/domain/storage/guilds/roles.rs | 117 + gateway/src/domain/storage/messages.rs | 149 + gateway/src/domain/storage/mod.rs | 223 ++ gateway/src/domain/storage/social.rs | 144 + gateway/src/handler/channel/create.rs | 251 ++ gateway/src/handler/channel/join.rs | 115 + gateway/src/handler/channel/leave.rs | 22 + gateway/src/handler/channel/mod.rs | 33 + gateway/src/handler/content/chat.rs | 66 + gateway/src/handler/content/message_edit.rs | 93 + gateway/src/handler/content/mod.rs | 9 + gateway/src/handler/content/presence.rs | 84 + gateway/src/handler/content/reaction.rs | 92 + gateway/src/handler/content/read_receipt.rs | 46 + gateway/src/handler/content/typing.rs | 30 + gateway/src/handler/deliver.rs | 39 + gateway/src/handler/direct_message/history.rs | 46 + gateway/src/handler/direct_message/mod.rs | 7 + gateway/src/handler/direct_message/send.rs | 113 + gateway/src/handler/direct_message/start.rs | 113 + gateway/src/handler/dispatch.rs | 187 ++ gateway/src/handler/friends/decline.rs | 37 + gateway/src/handler/friends/list.rs | 47 + gateway/src/handler/friends/manage.rs | 122 + gateway/src/handler/friends/mod.rs | 37 + gateway/src/handler/friends/requests.rs | 152 + gateway/src/handler/guild/audit.rs | 86 + gateway/src/handler/guild/crud.rs | 124 + gateway/src/handler/guild/invites.rs | 219 ++ gateway/src/handler/guild/list.rs | 61 + gateway/src/handler/guild/members.rs | 154 + gateway/src/handler/guild/members_list.rs | 230 ++ gateway/src/handler/guild/mod.rs | 68 + gateway/src/handler/guild/roles.rs | 128 + gateway/src/handler/mod.rs | 22 + gateway/src/handler/run.rs | 55 + gateway/src/lib.rs | 166 ++ gateway/src/main.rs | 12 + gateway/src/net/handshake.rs | 140 + gateway/src/net/io.rs | 135 + gateway/src/net/mod.rs | 3 + gateway/src/net/state.rs | 73 + gateway/src/proto/crypto/cipher.rs | 51 + gateway/src/proto/crypto/mod.rs | 112 + gateway/src/proto/framing.rs | 16 + gateway/src/proto/mod.rs | 11 + gateway/src/proto/packet/flags.rs | 6 + gateway/src/proto/packet/id.rs | 172 ++ gateway/src/proto/packet/mod.rs | 43 + gateway/src/proto/payloads/channel.rs | 157 + gateway/src/proto/payloads/guild.rs | 161 ++ gateway/src/proto/payloads/mod.rs | 7 + gateway/src/proto/payloads/social.rs | 132 + serverd/Cargo.toml | 23 + serverd/src/main.rs | 49 + voice-node/Cargo.toml | 26 + voice-node/src/jitter/adaptive.rs | 75 + voice-node/src/jitter/mod.rs | 100 + voice-node/src/jitter/relay.rs | 44 + voice-node/src/jitter/tests.rs | 103 + voice-node/src/lib.rs | 67 + voice-node/src/main.rs | 12 + voice-node/src/relay.rs | 97 + voice-node/src/runner.rs | 97 + 94 files changed, 9771 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .github/.github/ISSUE_TEMPLATE/bug.md create mode 100644 .github/.github/ISSUE_TEMPLATE/protocol.md create mode 100644 .github/.github/repo-metadata.md create mode 100644 .github/.github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 Dockerfile.voice-node create mode 100644 dev/dev/README.md create mode 100644 dev/dev/config.toml create mode 100644 docker-compose.yml create mode 100644 gateway/Cargo.toml create mode 100644 gateway/src/admin/metrics.rs create mode 100644 gateway/src/admin/mod.rs create mode 100644 gateway/src/bootstrap/mod.rs create mode 100644 gateway/src/bootstrap/server_identity.rs create mode 100644 gateway/src/domain/auth.rs create mode 100644 gateway/src/domain/channels/mod.rs create mode 100644 gateway/src/domain/channels/ops.rs create mode 100644 gateway/src/domain/config.rs create mode 100644 gateway/src/domain/mod.rs create mode 100644 gateway/src/domain/permissions.rs create mode 100644 gateway/src/domain/rate_limit/mod.rs create mode 100644 gateway/src/domain/session.rs create mode 100644 gateway/src/domain/storage/dm_unread.rs create mode 100644 gateway/src/domain/storage/dms.rs create mode 100644 gateway/src/domain/storage/guilds/audit.rs create mode 100644 gateway/src/domain/storage/guilds/mod.rs create mode 100644 gateway/src/domain/storage/guilds/roles.rs create mode 100644 gateway/src/domain/storage/messages.rs create mode 100644 gateway/src/domain/storage/mod.rs create mode 100644 gateway/src/domain/storage/social.rs create mode 100644 gateway/src/handler/channel/create.rs create mode 100644 gateway/src/handler/channel/join.rs create mode 100644 gateway/src/handler/channel/leave.rs create mode 100644 gateway/src/handler/channel/mod.rs create mode 100644 gateway/src/handler/content/chat.rs create mode 100644 gateway/src/handler/content/message_edit.rs create mode 100644 gateway/src/handler/content/mod.rs create mode 100644 gateway/src/handler/content/presence.rs create mode 100644 gateway/src/handler/content/reaction.rs create mode 100644 gateway/src/handler/content/read_receipt.rs create mode 100644 gateway/src/handler/content/typing.rs create mode 100644 gateway/src/handler/deliver.rs create mode 100644 gateway/src/handler/direct_message/history.rs create mode 100644 gateway/src/handler/direct_message/mod.rs create mode 100644 gateway/src/handler/direct_message/send.rs create mode 100644 gateway/src/handler/direct_message/start.rs create mode 100644 gateway/src/handler/dispatch.rs create mode 100644 gateway/src/handler/friends/decline.rs create mode 100644 gateway/src/handler/friends/list.rs create mode 100644 gateway/src/handler/friends/manage.rs create mode 100644 gateway/src/handler/friends/mod.rs create mode 100644 gateway/src/handler/friends/requests.rs create mode 100644 gateway/src/handler/guild/audit.rs create mode 100644 gateway/src/handler/guild/crud.rs create mode 100644 gateway/src/handler/guild/invites.rs create mode 100644 gateway/src/handler/guild/list.rs create mode 100644 gateway/src/handler/guild/members.rs create mode 100644 gateway/src/handler/guild/members_list.rs create mode 100644 gateway/src/handler/guild/mod.rs create mode 100644 gateway/src/handler/guild/roles.rs create mode 100644 gateway/src/handler/mod.rs create mode 100644 gateway/src/handler/run.rs create mode 100644 gateway/src/lib.rs create mode 100644 gateway/src/main.rs create mode 100644 gateway/src/net/handshake.rs create mode 100644 gateway/src/net/io.rs create mode 100644 gateway/src/net/mod.rs create mode 100644 gateway/src/net/state.rs create mode 100644 gateway/src/proto/crypto/cipher.rs create mode 100644 gateway/src/proto/crypto/mod.rs create mode 100644 gateway/src/proto/framing.rs create mode 100644 gateway/src/proto/mod.rs create mode 100644 gateway/src/proto/packet/flags.rs create mode 100644 gateway/src/proto/packet/id.rs create mode 100644 gateway/src/proto/packet/mod.rs create mode 100644 gateway/src/proto/payloads/channel.rs create mode 100644 gateway/src/proto/payloads/guild.rs create mode 100644 gateway/src/proto/payloads/mod.rs create mode 100644 gateway/src/proto/payloads/social.rs create mode 100644 serverd/Cargo.toml create mode 100644 serverd/src/main.rs create mode 100644 voice-node/Cargo.toml create mode 100644 voice-node/src/jitter/adaptive.rs create mode 100644 voice-node/src/jitter/mod.rs create mode 100644 voice-node/src/jitter/relay.rs create mode 100644 voice-node/src/jitter/tests.rs create mode 100644 voice-node/src/lib.rs create mode 100644 voice-node/src/main.rs create mode 100644 voice-node/src/relay.rs create mode 100644 voice-node/src/runner.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..664494e --- /dev/null +++ b/.cargo/config.toml @@ -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 diff --git a/.github/.github/ISSUE_TEMPLATE/bug.md b/.github/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..c8ccc07 --- /dev/null +++ b/.github/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Something is broken +labels: bug +--- + +## What happened + + + +## Expected behavior + + + +## 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) +``` diff --git a/.github/.github/ISSUE_TEMPLATE/protocol.md b/.github/.github/ISSUE_TEMPLATE/protocol.md new file mode 100644 index 0000000..f80254a --- /dev/null +++ b/.github/.github/ISSUE_TEMPLATE/protocol.md @@ -0,0 +1,25 @@ +--- +name: Protocol change proposal +about: Propose a change to LNEx protocol +labels: protocol +--- + +## Summary + + + +## Motivation + + + +## Proposed change + + + +## Backward compatibility + + + +## Alternatives considered + + diff --git a/.github/.github/repo-metadata.md b/.github/.github/repo-metadata.md new file mode 100644 index 0000000..0d88a69 --- /dev/null +++ b/.github/.github/repo-metadata.md @@ -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) diff --git a/.github/.github/workflows/ci.yml b/.github/.github/workflows/ci.yml new file mode 100644 index 0000000..ade9be9 --- /dev/null +++ b/.github/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ada9eac --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +dev/data/ +*.db +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9990bfd --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2545 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "audiopus_sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1 0.10.6", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "opus" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +dependencies = [ + "audiopus_sys", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1 0.10.6", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vnox-gateway" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "bitflags", + "chacha20poly1305", + "ed25519-dalek", + "hex", + "hkdf", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.11.0", + "sqlx", + "thiserror", + "tokio", + "toml", + "tower", + "tracing", + "tracing-subscriber", + "uuid", + "x25519-dalek", +] + +[[package]] +name = "vnox-serverd" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "vnox-gateway", + "vnox-voice-node", +] + +[[package]] +name = "vnox-voice-node" +version = "0.1.0" +dependencies = [ + "anyhow", + "opus", + "serde", + "thiserror", + "tokio", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..eb40a78 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a03740d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.voice-node b/Dockerfile.voice-node new file mode 100644 index 0000000..48486ce --- /dev/null +++ b/Dockerfile.voice-node @@ -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"] diff --git a/dev/dev/README.md b/dev/dev/README.md new file mode 100644 index 0000000..1fd72cf --- /dev/null +++ b/dev/dev/README.md @@ -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). diff --git a/dev/dev/config.toml b/dev/dev/config.toml new file mode 100644 index 0000000..9cc91d4 --- /dev/null +++ b/dev/dev/config.toml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0929324 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml new file mode 100644 index 0000000..7178dcc --- /dev/null +++ b/gateway/Cargo.toml @@ -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" diff --git a/gateway/src/admin/metrics.rs b/gateway/src/admin/metrics.rs new file mode 100644 index 0000000..b00c90c --- /dev/null +++ b/gateway/src/admin/metrics.rs @@ -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 { + 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. +#[allow(dead_code)] +pub fn bump(m: &Arc, field: fn(&Metrics) -> &AtomicU64) { + m.inc(field(m)); +} + +#[allow(dead_code)] +fn _silence_unused_helper(_start: Instant) {} diff --git a/gateway/src/admin/mod.rs b/gateway/src/admin/mod.rs new file mode 100644 index 0000000..949bc8e --- /dev/null +++ b/gateway/src/admin/mod.rs @@ -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, + pub sessions_count: std::sync::Arc, + pub channels_count: std::sync::Arc, +} + +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) -> 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) -> 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) -> impl IntoResponse { + let body = s.metrics.render_prometheus(&s); + ( + StatusCode::OK, + [("content-type", "text/plain; version=0.0.4")], + body, + ) +} diff --git a/gateway/src/bootstrap/mod.rs b/gateway/src/bootstrap/mod.rs new file mode 100644 index 0000000..eb1bacf --- /dev/null +++ b/gateway/src/bootstrap/mod.rs @@ -0,0 +1 @@ +pub mod server_identity; diff --git a/gateway/src/bootstrap/server_identity.rs b/gateway/src/bootstrap/server_identity.rs new file mode 100644 index 0000000..3a7d456 --- /dev/null +++ b/gateway/src/bootstrap/server_identity.rs @@ -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 { + 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 }) + } +} diff --git a/gateway/src/domain/auth.rs b/gateway/src/domain/auth.rs new file mode 100644 index 0000000..e7cba35 --- /dev/null +++ b/gateway/src/domain/auth.rs @@ -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()); + } +} diff --git a/gateway/src/domain/channels/mod.rs b/gateway/src/domain/channels/mod.rs new file mode 100644 index 0000000..71ebfb4 --- /dev/null +++ b/gateway/src/domain/channels/mod.rs @@ -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, +} + +pub type ChannelStore = Arc>>; + +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)) +} diff --git a/gateway/src/domain/channels/ops.rs b/gateway/src/domain/channels/ops.rs new file mode 100644 index 0000000..038c6c2 --- /dev/null +++ b/gateway/src/domain/channels/ops.rs @@ -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 { + 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 { + 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 { + store.read().await.values().cloned().collect() +} diff --git a/gateway/src/domain/config.rs b/gateway/src/domain/config.rs new file mode 100644 index 0000000..1b8dff2 --- /dev/null +++ b/gateway/src/domain/config.rs @@ -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, +} + +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, +} + +/// 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, +} + +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, + pub session_timeout: Option, + /// 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, + /// 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, + /// Burst size for the token bucket (messages allowed in one tick). + /// Defaults to 10. + #[serde(default)] + pub message_rate_burst: Option, +} + +#[derive(Debug, Deserialize)] +pub struct VoiceConfig { + pub bind: String, +} + +#[derive(Debug, Deserialize)] +pub struct StorageConfig { + pub data_dir: PathBuf, + pub backend: Option, + pub sqlite_path: Option, + pub postgres_url: Option, +} + +pub fn load() -> Result { + 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}")) +} diff --git a/gateway/src/domain/mod.rs b/gateway/src/domain/mod.rs new file mode 100644 index 0000000..2e62cdb --- /dev/null +++ b/gateway/src/domain/mod.rs @@ -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; diff --git a/gateway/src/domain/permissions.rs b/gateway/src/domain/permissions.rs new file mode 100644 index 0000000..93aa673 --- /dev/null +++ b/gateway/src/domain/permissions.rs @@ -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 + } +} diff --git a/gateway/src/domain/rate_limit/mod.rs b/gateway/src/domain/rate_limit/mod.rs new file mode 100644 index 0000000..d33d024 --- /dev/null +++ b/gateway/src/domain/rate_limit/mod.rs @@ -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>, + 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 = sessions.read().await.keys().cloned().collect(); + let live_set: std::collections::HashSet = 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")); + } +} diff --git a/gateway/src/domain/session.rs b/gateway/src/domain/session.rs new file mode 100644 index 0000000..977a975 --- /dev/null +++ b/gateway/src/domain/session.rs @@ -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, +} + +pub type SessionStore = Arc>>; + +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 { + 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 { + store + .read() + .await + .iter() + .find(|(_, s)| s.user_id == user_id) + .map(|(id, _)| id.clone()) +} diff --git a/gateway/src/domain/storage/dm_unread.rs b/gateway/src/domain/storage/dm_unread.rs new file mode 100644 index 0000000..b4fa6de --- /dev/null +++ b/gateway/src/domain/storage/dm_unread.rs @@ -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(()) + } +} diff --git a/gateway/src/domain/storage/dms.rs b/gateway/src/domain/storage/dms.rs new file mode 100644 index 0000000..ba8cf6b --- /dev/null +++ b/gateway/src/domain/storage/dms.rs @@ -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 { + 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, + ) -> Result> { + 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> { + 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> { + 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> { + self.get_dm_nickname(user_id).await + } +} diff --git a/gateway/src/domain/storage/guilds/audit.rs b/gateway/src/domain/storage/guilds/audit.rs new file mode 100644 index 0000000..472c7f5 --- /dev/null +++ b/gateway/src/domain/storage/guilds/audit.rs @@ -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, + #[sqlx(default)] + pub target_type: Option, + #[sqlx(default)] + pub reason: Option, + 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> { + 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?) + } +} diff --git a/gateway/src/domain/storage/guilds/mod.rs b/gateway/src/domain/storage/guilds/mod.rs new file mode 100644 index 0000000..289afbc --- /dev/null +++ b/gateway/src/domain/storage/guilds/mod.rs @@ -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, + pub uses: i64, + pub expires_at: Option, + pub created_at: i64, +} + +impl super::Storage { + pub async fn create_guild(&self, owner_id: &str, name: &str) -> Result { + 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> { + 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> { + 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> { + 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> { + 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, +} diff --git a/gateway/src/domain/storage/guilds/roles.rs b/gateway/src/domain/storage/guilds/roles.rs new file mode 100644 index 0000000..cbd870d --- /dev/null +++ b/gateway/src/domain/storage/guilds/roles.rs @@ -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 { + 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> { + 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> { + #[derive(sqlx::FromRow)] + struct P { + permissions: i64, + } + let rows: Vec

= 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, + expires_in_s: Option, + ) -> Result { + 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> { + 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(()) + } +} diff --git a/gateway/src/domain/storage/messages.rs b/gateway/src/domain/storage/messages.rs new file mode 100644 index 0000000..898eee0 --- /dev/null +++ b/gateway/src/domain/storage/messages.rs @@ -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, +} + +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> { + 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 { + 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> { + 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> { + 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, + })) + } +} diff --git a/gateway/src/domain/storage/mod.rs b/gateway/src/domain/storage/mod.rs new file mode 100644 index 0000000..bfdd2b8 --- /dev/null +++ b/gateway/src/domain/storage/mod.rs @@ -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 { + 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, i64); + let pragma = format!("PRAGMA table_info({table})"); + let rows: Result, _> = + 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 { + 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 +} diff --git a/gateway/src/domain/storage/social.rs b/gateway/src/domain/storage/social.rs new file mode 100644 index 0000000..cbc04c0 --- /dev/null +++ b/gateway/src/domain/storage/social.rs @@ -0,0 +1,144 @@ +use anyhow::Result; + +impl super::Storage { + pub async fn create_friend_request(&self, from_id: &str, to_id: &str) -> Result { + 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 { + 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> { + 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 { + 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 { + 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> { + 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(), + ) + } +} diff --git a/gateway/src/handler/channel/create.rs b/gateway/src/handler/channel/create.rs new file mode 100644 index 0000000..f60f9af --- /dev/null +++ b/gateway/src/handler/channel/create.rs @@ -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 = 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(()) +} diff --git a/gateway/src/handler/channel/join.rs b/gateway/src/handler/channel/join.rs new file mode 100644 index 0000000..d6f0606 --- /dev/null +++ b/gateway/src/handler/channel/join.rs @@ -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(()) +} diff --git a/gateway/src/handler/channel/leave.rs b/gateway/src/handler/channel/leave.rs new file mode 100644 index 0000000..9e23845 --- /dev/null +++ b/gateway/src/handler/channel/leave.rs @@ -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(()) +} diff --git a/gateway/src/handler/channel/mod.rs b/gateway/src/handler/channel/mod.rs new file mode 100644 index 0000000..04d60eb --- /dev/null +++ b/gateway/src/handler/channel/mod.rs @@ -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) { + if let Some(s) = state.sessions.write().await.get_mut(session_id) { + s.channel_id = ch; + } +} diff --git a/gateway/src/handler/content/chat.rs b/gateway/src/handler/content/chat.rs new file mode 100644 index 0000000..3acb372 --- /dev/null +++ b/gateway/src/handler/content/chat.rs @@ -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 +} diff --git a/gateway/src/handler/content/message_edit.rs b/gateway/src/handler/content/message_edit.rs new file mode 100644 index 0000000..59f293a --- /dev/null +++ b/gateway/src/handler/content/message_edit.rs @@ -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(()) +} diff --git a/gateway/src/handler/content/mod.rs b/gateway/src/handler/content/mod.rs new file mode 100644 index 0000000..9cdcbd9 --- /dev/null +++ b/gateway/src/handler/content/mod.rs @@ -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; diff --git a/gateway/src/handler/content/presence.rs b/gateway/src/handler/content/presence.rs new file mode 100644 index 0000000..791837d --- /dev/null +++ b/gateway/src/handler/content/presence.rs @@ -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 = state.presences.read().await.values().cloned().collect(); + + io::send_encrypted( + stream, + PacketId::PresenceSync, + seq, + &to_payload(&PresenceSyncPayload { presences }), + crypto, + ) + .await?; + Ok(()) +} diff --git a/gateway/src/handler/content/reaction.rs b/gateway/src/handler/content/reaction.rs new file mode 100644 index 0000000..1e3b561 --- /dev/null +++ b/gateway/src/handler/content/reaction.rs @@ -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(()) +} diff --git a/gateway/src/handler/content/read_receipt.rs b/gateway/src/handler/content/read_receipt.rs new file mode 100644 index 0000000..522bdf9 --- /dev/null +++ b/gateway/src/handler/content/read_receipt.rs @@ -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(()) +} diff --git a/gateway/src/handler/content/typing.rs b/gateway/src/handler/content/typing.rs new file mode 100644 index 0000000..22d4647 --- /dev/null +++ b/gateway/src/handler/content/typing.rs @@ -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(()) +} diff --git a/gateway/src/handler/deliver.rs b/gateway/src/handler/deliver.rs new file mode 100644 index 0000000..a30f20a --- /dev/null +++ b/gateway/src/handler/deliver.rs @@ -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 +} diff --git a/gateway/src/handler/direct_message/history.rs b/gateway/src/handler/direct_message/history.rs new file mode 100644 index 0000000..8ba462a --- /dev/null +++ b/gateway/src/handler/direct_message/history.rs @@ -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(()) +} diff --git a/gateway/src/handler/direct_message/mod.rs b/gateway/src/handler/direct_message/mod.rs new file mode 100644 index 0000000..4658f2a --- /dev/null +++ b/gateway/src/handler/direct_message/mod.rs @@ -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}; diff --git a/gateway/src/handler/direct_message/send.rs b/gateway/src/handler/direct_message/send.rs new file mode 100644 index 0000000..6e2743c --- /dev/null +++ b/gateway/src/handler/direct_message/send.rs @@ -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(()) +} diff --git a/gateway/src/handler/direct_message/start.rs b/gateway/src/handler/direct_message/start.rs new file mode 100644 index 0000000..8c09f58 --- /dev/null +++ b/gateway/src/handler/direct_message/start.rs @@ -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(()) +} diff --git a/gateway/src/handler/dispatch.rs b/gateway/src/handler/dispatch.rs new file mode 100644 index 0000000..f117e07 --- /dev/null +++ b/gateway/src/handler/dispatch.rs @@ -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(()) +} diff --git a/gateway/src/handler/friends/decline.rs b/gateway/src/handler/friends/decline.rs new file mode 100644 index 0000000..7057884 --- /dev/null +++ b/gateway/src/handler/friends/decline.rs @@ -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(()) +} diff --git a/gateway/src/handler/friends/list.rs b/gateway/src/handler/friends/list.rs new file mode 100644 index 0000000..4c2a679 --- /dev/null +++ b/gateway/src/handler/friends/list.rs @@ -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(()) +} diff --git a/gateway/src/handler/friends/manage.rs b/gateway/src/handler/friends/manage.rs new file mode 100644 index 0000000..4f1cbd1 --- /dev/null +++ b/gateway/src/handler/friends/manage.rs @@ -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(()) +} diff --git a/gateway/src/handler/friends/mod.rs b/gateway/src/handler/friends/mod.rs new file mode 100644 index 0000000..034855f --- /dev/null +++ b/gateway/src/handler/friends/mod.rs @@ -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}; diff --git a/gateway/src/handler/friends/requests.rs b/gateway/src/handler/friends/requests.rs new file mode 100644 index 0000000..b9c9182 --- /dev/null +++ b/gateway/src/handler/friends/requests.rs @@ -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(()) +} diff --git a/gateway/src/handler/guild/audit.rs b/gateway/src/handler/guild/audit.rs new file mode 100644 index 0000000..3f505ec --- /dev/null +++ b/gateway/src/handler/guild/audit.rs @@ -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 = 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(()) +} diff --git a/gateway/src/handler/guild/crud.rs b/gateway/src/handler/guild/crud.rs new file mode 100644 index 0000000..b48a427 --- /dev/null +++ b/gateway/src/handler/guild/crud.rs @@ -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(()) +} diff --git a/gateway/src/handler/guild/invites.rs b/gateway/src/handler/guild/invites.rs new file mode 100644 index 0000000..37d9b37 --- /dev/null +++ b/gateway/src/handler/guild/invites.rs @@ -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(()) +} diff --git a/gateway/src/handler/guild/list.rs b/gateway/src/handler/guild/list.rs new file mode 100644 index 0000000..626d382 --- /dev/null +++ b/gateway/src/handler/guild/list.rs @@ -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 = 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(()) +} diff --git a/gateway/src/handler/guild/members.rs b/gateway/src/handler/guild/members.rs new file mode 100644 index 0000000..2ba90d3 --- /dev/null +++ b/gateway/src/handler/guild/members.rs @@ -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(()) +} diff --git a/gateway/src/handler/guild/members_list.rs b/gateway/src/handler/guild/members_list.rs new file mode 100644 index 0000000..9ff6fb5 --- /dev/null +++ b/gateway/src/handler/guild/members_list.rs @@ -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 = 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 = 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(()) +} diff --git a/gateway/src/handler/guild/mod.rs b/gateway/src/handler/guild/mod.rs new file mode 100644 index 0000000..8dc6052 --- /dev/null +++ b/gateway/src/handler/guild/mod.rs @@ -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 { + 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}; diff --git a/gateway/src/handler/guild/roles.rs b/gateway/src/handler/guild/roles.rs new file mode 100644 index 0000000..4e2fe59 --- /dev/null +++ b/gateway/src/handler/guild/roles.rs @@ -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(()) +} diff --git a/gateway/src/handler/mod.rs b/gateway/src/handler/mod.rs new file mode 100644 index 0000000..06a6eed --- /dev/null +++ b/gateway/src/handler/mod.rs @@ -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, +} diff --git a/gateway/src/handler/run.rs b/gateway/src/handler/run.rs new file mode 100644 index 0000000..70098c3 --- /dev/null +++ b/gateway/src/handler/run.rs @@ -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, +) -> 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(()), + } + } + } + } +} diff --git a/gateway/src/lib.rs b/gateway/src/lib.rs new file mode 100644 index 0000000..c7553be --- /dev/null +++ b/gateway/src/lib.rs @@ -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) -> 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 +} diff --git a/gateway/src/main.rs b/gateway/src/main.rs new file mode 100644 index 0000000..35ba47e --- /dev/null +++ b/gateway/src/main.rs @@ -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 +} diff --git a/gateway/src/net/handshake.rs b/gateway/src/net/handshake.rs new file mode 100644 index 0000000..66a13a6 --- /dev/null +++ b/gateway/src/net/handshake.rs @@ -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() +} diff --git a/gateway/src/net/io.rs b/gateway/src/net/io.rs new file mode 100644 index 0000000..9c6c679 --- /dev/null +++ b/gateway/src/net/io.rs @@ -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)> { + 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)> { + 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 +} diff --git a/gateway/src/net/mod.rs b/gateway/src/net/mod.rs new file mode 100644 index 0000000..81e2d89 --- /dev/null +++ b/gateway/src/net/mod.rs @@ -0,0 +1,3 @@ +pub mod handshake; +pub mod io; +pub mod state; diff --git a/gateway/src/net/state.rs b/gateway/src/net/state.rs new file mode 100644 index 0000000..c05e9b4 --- /dev/null +++ b/gateway/src/net/state.rs @@ -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, + pub config: Arc, + pub server_identity: Arc, + pub broadcast: broadcast::Sender, + /// user_id → presence info (Phase 1.2) + pub presences: Arc>>, + /// Prometheus-style counters. + pub metrics: Arc, + /// Live count of authenticated sessions. + pub sessions_count: Arc, + /// Live count of channels with at least one member. + #[allow(dead_code)] + pub channels_count: Arc, + /// Per-session rate limiter (token bucket). + pub rate_limiter: Arc, +} + +impl State { + #[allow(clippy::too_many_arguments)] + pub fn new( + sessions: SessionStore, + channels: ChannelStore, + storage: Arc, + config: Arc, + server_identity: Arc, + broadcast: broadcast::Sender, + metrics: Arc, + sessions_count: Arc, + channels_count: Arc, + ) -> 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, + pub exclude_session: Option, + pub target_session_id: Option, + pub data: Vec, +} diff --git a/gateway/src/proto/crypto/cipher.rs b/gateway/src/proto/crypto/cipher.rs new file mode 100644 index 0000000..8944105 --- /dev/null +++ b/gateway/src/proto/crypto/cipher.rs @@ -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 { + 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> { + 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:?}")) +} diff --git a/gateway/src/proto/crypto/mod.rs b/gateway/src/proto/crypto/mod.rs new file mode 100644 index 0000000..ea16207 --- /dev/null +++ b/gateway/src/proto/crypto/mod.rs @@ -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::::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 { + cipher::encrypt_with_key(&self.s2c_key, &self.cid, seq, plaintext) + } + + pub fn decrypt_c2s(&self, seq: u64, ciphertext: &[u8]) -> anyhow::Result> { + 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()); + } +} diff --git a/gateway/src/proto/framing.rs b/gateway/src/proto/framing.rs new file mode 100644 index 0000000..4723c9c --- /dev/null +++ b/gateway/src/proto/framing.rs @@ -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 { + 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(v: &T) -> Vec { + serde_json::to_vec(v).expect("payload serialization is infallible") +} diff --git a/gateway/src/proto/mod.rs b/gateway/src/proto/mod.rs new file mode 100644 index 0000000..d05f767 --- /dev/null +++ b/gateway/src/proto/mod.rs @@ -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::*; diff --git a/gateway/src/proto/packet/flags.rs b/gateway/src/proto/packet/flags.rs new file mode 100644 index 0000000..1c40788 --- /dev/null +++ b/gateway/src/proto/packet/flags.rs @@ -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; diff --git a/gateway/src/proto/packet/id.rs b/gateway/src/proto/packet/id.rs new file mode 100644 index 0000000..01515a7 --- /dev/null +++ b/gateway/src/proto/packet/id.rs @@ -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 { + 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 { + 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, + } + } +} diff --git a/gateway/src/proto/packet/mod.rs b/gateway/src/proto/packet/mod.rs new file mode 100644 index 0000000..746ec5a --- /dev/null +++ b/gateway/src/proto/packet/mod.rs @@ -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]]), + } + } +} diff --git a/gateway/src/proto/payloads/channel.rs b/gateway/src/proto/payloads/channel.rs new file mode 100644 index 0000000..f058d86 --- /dev/null +++ b/gateway/src/proto/payloads/channel.rs @@ -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, + 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, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ChannelDeletePayload { + pub channel_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ChannelListPayload { + pub channels: Vec, +} + +#[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, +} + +#[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, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorPayload { + pub code: u32, + pub message: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DisconnectPayload { + pub reason: String, +} diff --git a/gateway/src/proto/payloads/guild.rs b/gateway/src/proto/payloads/guild.rs new file mode 100644 index 0000000..1d56532 --- /dev/null +++ b/gateway/src/proto/payloads/guild.rs @@ -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, +} + +#[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, + pub permissions: Option, +} + +#[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, + pub expires_in_seconds: Option, +} + +#[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, + pub uses: i64, + pub expires_at: Option, + 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, + pub target_type: Option, + pub reason: Option, + pub created_at: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GuildAuditLogPayload { + pub guild_id: String, + pub entries: Vec, +} + +#[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, +} + +#[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, +} diff --git a/gateway/src/proto/payloads/mod.rs b/gateway/src/proto/payloads/mod.rs new file mode 100644 index 0000000..23ae5b4 --- /dev/null +++ b/gateway/src/proto/payloads/mod.rs @@ -0,0 +1,7 @@ +mod channel; +mod guild; +mod social; + +pub use channel::*; +pub use guild::*; +pub use social::*; diff --git a/gateway/src/proto/payloads/social.rs b/gateway/src/proto/payloads/social.rs new file mode 100644 index 0000000..cefe808 --- /dev/null +++ b/gateway/src/proto/payloads/social.rs @@ -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, + #[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, + #[serde(default)] + pub search_query: Option, + #[serde(default)] + pub limit: Option, +} + +#[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, +} + +#[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, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FriendEventPayload { + pub event: String, + pub user_id: String, + pub nickname: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PresenceUpdatePayload { + pub status: String, + pub activity_type: Option, + pub activity_text: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PresenceInfo { + pub user_id: String, + pub nickname: String, + pub status: String, + pub activity_type: Option, + pub activity_text: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PresenceSyncPayload { + pub presences: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PresenceEventPayload { + pub user_id: String, + pub nickname: String, + pub status: String, + pub activity_type: Option, + pub activity_text: Option, +} + +#[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, +} diff --git a/serverd/Cargo.toml b/serverd/Cargo.toml new file mode 100644 index 0000000..1375965 --- /dev/null +++ b/serverd/Cargo.toml @@ -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 diff --git a/serverd/src/main.rs b/serverd/src/main.rs new file mode 100644 index 0000000..2646ae4 --- /dev/null +++ b/serverd/src/main.rs @@ -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(()) +} diff --git a/voice-node/Cargo.toml b/voice-node/Cargo.toml new file mode 100644 index 0000000..5e6c906 --- /dev/null +++ b/voice-node/Cargo.toml @@ -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 diff --git a/voice-node/src/jitter/adaptive.rs b/voice-node/src/jitter/adaptive.rs new file mode 100644 index 0000000..7ca0edc --- /dev/null +++ b/voice-node/src/jitter/adaptive.rs @@ -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; + } +} diff --git a/voice-node/src/jitter/mod.rs b/voice-node/src/jitter/mod.rs new file mode 100644 index 0000000..fb0e0fa --- /dev/null +++ b/voice-node/src/jitter/mod.rs @@ -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, + last_played: Option, + arrival_times: Vec, + 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, + pub arrived_at: u64, +} + +#[cfg(test)] +mod tests; diff --git a/voice-node/src/jitter/relay.rs b/voice-node/src/jitter/relay.rs new file mode 100644 index 0000000..6bc07b5 --- /dev/null +++ b/voice-node/src/jitter/relay.rs @@ -0,0 +1,44 @@ +use super::{BufferedPacket, JitterBuffer}; + +impl JitterBuffer { + pub fn pop_ready(&mut self, now_ms: u64) -> Option { + 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 { + 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 { + 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 + } +} diff --git a/voice-node/src/jitter/tests.rs b/voice-node/src/jitter/tests.rs new file mode 100644 index 0000000..fa8dc3e --- /dev/null +++ b/voice-node/src/jitter/tests.rs @@ -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)"); +} diff --git a/voice-node/src/lib.rs b/voice-node/src/lib.rs new file mode 100644 index 0000000..25343b2 --- /dev/null +++ b/voice-node/src/lib.rs @@ -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 { + 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 { + 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], + ]), + }) + } +} diff --git a/voice-node/src/main.rs b/voice-node/src/main.rs new file mode 100644 index 0000000..108d89b --- /dev/null +++ b/voice-node/src/main.rs @@ -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 +} diff --git a/voice-node/src/relay.rs b/voice-node/src/relay.rs new file mode 100644 index 0000000..ba1e985 --- /dev/null +++ b/voice-node/src/relay.rs @@ -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, + /// reorders and smooths voice packets + pub jitter: JitterBuffer, + /// voice_seq → original sender address (for relay after pop) + pub senders: HashMap, +} + +/// Maps channel_id → per-channel state. +pub type ChannelMap = Arc>>; + +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()); +} diff --git a/voice-node/src/runner.rs b/voice-node/src/runner.rs new file mode 100644 index 0000000..c78ad2e --- /dev/null +++ b/voice-node/src/runner.rs @@ -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; + } +}