Compare commits
41 Commits
4a4fa9fab4
...
5764719375
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5764719375 | ||
|
|
a368ab24d2 | ||
|
|
3429f518b1 | ||
|
|
e9182fdb41 | ||
|
|
0b58ddcee5 | ||
|
|
0e7277fb20 | ||
|
|
7628ff7a75 | ||
|
|
3489a7cf74 | ||
|
|
1e47b888c8 | ||
|
|
5415d1f5c8 | ||
|
|
13f2227bf0 | ||
|
|
f04c24187d | ||
|
|
3e583bb04b | ||
|
|
6fee73fc4d | ||
|
|
8b37bd4323 | ||
|
|
b0fa9f92bd | ||
|
|
4118be7ef3 | ||
|
|
76fd8dd81a | ||
|
|
e0e747e005 | ||
|
|
76ee2ab585 | ||
|
|
878847ce89 | ||
|
|
362e7a765b | ||
|
|
9dd7341809 | ||
|
|
6196057f3e | ||
|
|
76cac77259 | ||
|
|
8603087afb | ||
|
|
067f1ea20b | ||
|
|
b9e7b3e05c | ||
|
|
deb220ff2c | ||
|
|
0697c988fa | ||
|
|
1851728a09 | ||
|
|
ea04405199 | ||
|
|
2aa58a4319 | ||
|
|
3efce2ddf4 | ||
|
|
fcbf2d5859 | ||
|
|
953b3bd13a | ||
|
|
210fbbb35b | ||
|
|
7b72f7cba5 | ||
|
|
dbf5d136cf | ||
|
|
f8eaf30bb4 | ||
|
|
3e0889e5dc |
Submodule warzone-phone updated: 237adbbf21...6f4e8eb9f6
88
warzone/CLAUDE.md
Normal file
88
warzone/CLAUDE.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# featherChat — Design Principles & Conventions
|
||||
|
||||
## MANDATORY: Version Bumping
|
||||
|
||||
**After every set of changes that modifies functionality, bump the version:**
|
||||
1. `Cargo.toml` workspace version (e.g. `0.0.22` → `0.0.23`)
|
||||
2. `crates/warzone-protocol/Cargo.toml` standalone version (same)
|
||||
3. `crates/warzone-server/src/routes/web.rs` JS `VERSION` constant
|
||||
4. `crates/warzone-server/src/routes/web.rs` service worker `CACHE` version (`wz-vN` → `wz-v(N+1)`)
|
||||
|
||||
Never commit functional changes without bumping all four. The service worker cache MUST be bumped or browsers will serve stale WASM.
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
1. **Single seed, multiple identities** — Ed25519 (messaging), X25519 (encryption), secp256k1 (ETH address) all derived from one BIP39 seed via HKDF with domain-separated info strings.
|
||||
|
||||
2. **E2E by default** — All user messages are Double Ratchet encrypted. The server NEVER sees plaintext. Friend lists are client-side encrypted. Only bot messages are plaintext (v1).
|
||||
|
||||
3. **Server is semi-trusted** — Server sees metadata (who talks to whom, timing, groups) but cannot read message content. Design all features with this trust boundary in mind.
|
||||
|
||||
4. **Federation is transparent** — Users don't need to know which server their peer is on. Key lookup, alias resolution, and message delivery automatically proxy through federation.
|
||||
|
||||
5. **Telegram Bot API compatibility** — Bot API follows Telegram conventions (getUpdates, sendMessage, token-in-URL). Bot aliases must end with Bot/bot/_bot.
|
||||
|
||||
6. **Auth on writes, open reads** — All POST/write endpoints require bearer tokens. GET/read endpoints are public (needed for key exchange before auth is possible).
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
### Rust
|
||||
- Workspace crates: protocol (no I/O), server (axum), client (ratatui), wasm (wasm-bindgen), mule (future)
|
||||
- Error handling: `AppResult<T>` in server, `anyhow::Result` in client, `ProtocolError` in protocol
|
||||
- State: `AppState` with `Arc<Mutex<>>` for shared state, `Arc<Database>` for sled
|
||||
- Auth: `AuthFingerprint` extractor as first handler param for protected routes
|
||||
- Fingerprints: always normalize with `normfp()` (strip non-hex, lowercase)
|
||||
- New routes: create `routes/<name>.rs`, add `pub fn routes() -> Router<AppState>`, merge in `routes/mod.rs`
|
||||
|
||||
### TUI
|
||||
- 7 modules in `tui/`: types, draw, commands, input, file_transfer, network, mod
|
||||
- All ChatLine must include `timestamp: Local::now()`
|
||||
- Add new commands to both the handler chain AND `/help` text
|
||||
- Self-messaging prevention: check `normfp(&peer) != normfp(&self.our_fp)`
|
||||
|
||||
### Web (WASM)
|
||||
- JS embedded in `routes/web.rs` as Rust raw string — careful with escaping
|
||||
- Service worker cache version must be bumped on WASM changes (`wz-vN`)
|
||||
- `WasmSession::initiate()` stores X3DH result — `encrypt_key_exchange` must NOT re-initiate
|
||||
|
||||
### Federation
|
||||
- Persistent WS between servers, NOT HTTP polling
|
||||
- Presence re-pushed every 10s + on connect
|
||||
- Key lookup: proxy to peer for non-local fingerprints (never cache remote bundles)
|
||||
- Alias resolution: fall back to peer if not found locally
|
||||
- Registration: check peer to enforce global uniqueness
|
||||
|
||||
### Bot API
|
||||
- Token stored as `bot:<token>` in tokens tree
|
||||
- Reverse lookup: `bot_fp:<fingerprint>` → token
|
||||
- Alias auto-registered on bot creation with `_bot` suffix
|
||||
- Reserved aliases: `*Bot`, `*bot`, `*_bot` blocked for non-bots
|
||||
|
||||
## Task Naming
|
||||
|
||||
`FC-P{phase}-T{task}[-S{subtask}]`
|
||||
|
||||
See `docs/TASK_PLAN.md` for the full breakdown.
|
||||
|
||||
## Testing
|
||||
|
||||
- Protocol: unit tests in each module's `#[cfg(test)]`
|
||||
- TUI: unit tests for types, input, draw (using ratatui TestBackend)
|
||||
- WASM: can't test natively (js-sys dependency) — test equivalent logic in protocol crate
|
||||
- Server: no integration tests yet (planned)
|
||||
|
||||
## Key Files
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| Wire format | `warzone-protocol/src/message.rs` |
|
||||
| Crypto primitives | `warzone-protocol/src/crypto.rs` |
|
||||
| Server state | `warzone-server/src/state.rs` |
|
||||
| All routes | `warzone-server/src/routes/mod.rs` |
|
||||
| Federation | `warzone-server/src/federation.rs` |
|
||||
| TUI commands | `warzone-client/src/tui/commands.rs` |
|
||||
| Web client | `warzone-server/src/routes/web.rs` |
|
||||
| WASM bridge | `warzone-wasm/src/lib.rs` |
|
||||
| Task plan | `docs/TASK_PLAN.md` |
|
||||
| Bot API docs | `docs/BOT_API.md` |
|
||||
| LLM help ref | `docs/LLM_HELP.md` |
|
||||
241
warzone/Cargo.lock
generated
241
warzone/Cargo.lock
generated
@@ -163,7 +163,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -317,6 +317,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
@@ -529,7 +535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -541,7 +547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -680,7 +686,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
@@ -706,7 +712,7 @@ dependencies = [
|
||||
"generic-array",
|
||||
"group",
|
||||
"pkcs8",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"serdect",
|
||||
"subtle",
|
||||
@@ -750,7 +756,7 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -897,6 +903,20 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
@@ -905,7 +925,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@@ -917,7 +937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -1078,6 +1098,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1433,6 +1454,12 @@ dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -1624,7 +1651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -1716,6 +1743,61 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -1725,6 +1807,12 @@ 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"
|
||||
@@ -1738,8 +1826,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1749,7 +1847,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"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]]
|
||||
@@ -1761,6 +1869,15 @@ 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 = "ratatui"
|
||||
version = "0.28.1"
|
||||
@@ -1841,6 +1958,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1848,6 +1967,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tower 0.5.3",
|
||||
"tower-http 0.6.8",
|
||||
"tower-service",
|
||||
@@ -1855,6 +1975,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1881,6 +2002,12 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -1923,6 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
@@ -1935,6 +2063,7 @@ version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2171,7 +2300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2464,6 +2593,20 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tungstenite 0.21.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.24.0"
|
||||
@@ -2475,7 +2618,7 @@ dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tungstenite",
|
||||
"tungstenite 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2497,6 +2640,10 @@ version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -2633,6 +2780,26 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.24.0"
|
||||
@@ -2646,7 +2813,7 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
@@ -2789,7 +2956,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-client"
|
||||
version = "0.0.21"
|
||||
version = "0.0.38"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -2802,7 +2969,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
"libc",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"ratatui",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -2810,7 +2977,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"sled",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
@@ -2822,7 +2989,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-mule"
|
||||
version = "0.0.21"
|
||||
version = "0.0.38"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -2831,7 +2998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.21"
|
||||
version = "0.0.38"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
@@ -2843,7 +3010,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hkdf",
|
||||
"k256",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -2856,7 +3023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-server"
|
||||
version = "0.0.21"
|
||||
version = "0.0.38"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2867,12 +3034,15 @@ dependencies = [
|
||||
"ed25519-dalek",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sled",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.21.0",
|
||||
"tower 0.4.13",
|
||||
"tower-http 0.5.2",
|
||||
"tracing",
|
||||
@@ -2883,7 +3053,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-wasm"
|
||||
version = "0.0.21"
|
||||
version = "0.0.38"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
@@ -2891,7 +3061,7 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
"js-sys",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
@@ -3028,6 +3198,25 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -3312,7 +3501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -9,7 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.21"
|
||||
version = "0.0.38"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
@@ -42,7 +42,7 @@ tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Server
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
tower = "0.4"
|
||||
tower = { version = "0.4", features = ["limit"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||
|
||||
# Client HTTP
|
||||
@@ -78,5 +78,8 @@ base64 = "0.22"
|
||||
# UUID
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
# WebSocket client
|
||||
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
|
||||
|
||||
# Zero secrets in memory
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
165
warzone/README.md
Normal file
165
warzone/README.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Warzone Messenger (featherChat)
|
||||
|
||||
End-to-end encrypted messenger with Signal protocol cryptography, voice/video call integration, and server federation.
|
||||
|
||||
## Features
|
||||
|
||||
- **E2E Encrypted DMs** — X3DH key exchange + Double Ratchet (forward secrecy)
|
||||
- **Group Messaging** — Sender Key protocol (O(1) encryption, fan-out delivery)
|
||||
- **File Transfer** — Chunked (64KB), SHA-256 verified, ratchet-encrypted
|
||||
- **Voice/Video Calls** — WarzonePhone integration (QUIC SFU relay, ChaCha20-Poly1305 media)
|
||||
- **Federation** — Two-server relay with HMAC-authenticated presence sync
|
||||
- **TUI Client** — Full-featured terminal UI (ratatui, timestamps, scrolling, receipts)
|
||||
- **Web Client** — Identical crypto via WASM (wasm-bindgen)
|
||||
- **Ethereum Identity** — Same seed derives messaging keypair + Ethereum address (secp256k1)
|
||||
- **BIP39 Seed** — 24-word mnemonic for identity backup/recovery
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Clients (CLI / TUI / Web)
|
||||
|
|
||||
| E2E encrypted (ChaCha20-Poly1305)
|
||||
|
|
||||
warzone-server (axum + sled)
|
||||
|
|
||||
| Federation (HTTP + HMAC)
|
||||
|
|
||||
warzone-server (peer)
|
||||
|
|
||||
| Call signaling
|
||||
|
|
||||
WarzonePhone Relay (QUIC SFU)
|
||||
```
|
||||
|
||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for full architecture with Mermaid diagrams.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd warzone
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Generate Identity
|
||||
|
||||
```bash
|
||||
./target/release/warzone-client init
|
||||
# Outputs: 24-word BIP39 mnemonic + fingerprint
|
||||
```
|
||||
|
||||
### Start Server
|
||||
|
||||
```bash
|
||||
./target/release/warzone-server --bind 0.0.0.0:7700
|
||||
```
|
||||
|
||||
### Start TUI
|
||||
|
||||
```bash
|
||||
./target/release/warzone-client tui --server http://localhost:7700
|
||||
```
|
||||
|
||||
### Federation (Two Servers)
|
||||
|
||||
Create `alpha.json`:
|
||||
```json
|
||||
{
|
||||
"server_id": "alpha",
|
||||
"shared_secret": "your-shared-secret",
|
||||
"peer": { "id": "bravo", "url": "http://server-b:7700" },
|
||||
"presence_interval_secs": 5
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Server A
|
||||
warzone-server --bind 0.0.0.0:7700 --federation alpha.json
|
||||
|
||||
# Server B
|
||||
warzone-server --bind 0.0.0.0:7700 --federation bravo.json
|
||||
```
|
||||
|
||||
Messages automatically route across servers.
|
||||
|
||||
## TUI Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/peer <fp>` or `/p @alias` | Set DM peer |
|
||||
| `/g <name>` | Switch to group (auto-join) |
|
||||
| `/call <fp>` | Initiate call |
|
||||
| `/file <path>` | Send file (max 10MB) |
|
||||
| `/contacts` | List contacts with message counts |
|
||||
| `/history` | Show conversation history |
|
||||
| `/devices` | List active device sessions |
|
||||
| `/kick <id>` | Revoke a device session |
|
||||
| `/help` | Full command list |
|
||||
|
||||
## Crates
|
||||
|
||||
| Crate | Purpose |
|
||||
|-------|---------|
|
||||
| `warzone-protocol` | Crypto & message types (X3DH, Double Ratchet, Sender Keys) |
|
||||
| `warzone-server` | HTTP/WS server with sled DB, federation, call state |
|
||||
| `warzone-client` | CLI + TUI client |
|
||||
| `warzone-wasm` | WASM bridge for web client |
|
||||
| `warzone-mule` | Physical message delivery (planned) |
|
||||
|
||||
## Cryptographic Stack
|
||||
|
||||
| Primitive | Purpose |
|
||||
|-----------|---------|
|
||||
| Ed25519 | Identity signing |
|
||||
| X25519 | Diffie-Hellman key exchange |
|
||||
| ChaCha20-Poly1305 | AEAD encryption |
|
||||
| HKDF-SHA256 | Key derivation |
|
||||
| Argon2id | Seed encryption at rest |
|
||||
| secp256k1 | Ethereum-compatible identity |
|
||||
|
||||
## Security
|
||||
|
||||
- Auth enforcement on all write routes (bearer token middleware)
|
||||
- Session auto-recovery on ratchet corruption
|
||||
- Per-fingerprint WS connection cap (5 devices)
|
||||
- Global request concurrency limit (200)
|
||||
- Device management (list, kick, revoke-all panic button)
|
||||
- Federation auth: SHA-256(secret || body) on every inter-server request
|
||||
|
||||
See [docs/SECURITY.md](docs/SECURITY.md) for the full threat model.
|
||||
|
||||
## Test Suite
|
||||
|
||||
72 tests across protocol + client crates (all passing):
|
||||
- 28 protocol tests (X3DH, Double Ratchet, Sender Keys, crypto, identity)
|
||||
- 44 TUI tests (rendering, keyboard input, scrolling, state management)
|
||||
|
||||
```bash
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
## WarzonePhone Integration
|
||||
|
||||
All 9 WZP-side integration tasks are complete:
|
||||
- Shared identity (HKDF alignment, 15 cross-project tests)
|
||||
- Relay auth (featherChat bearer token validation)
|
||||
- Signaling bridge (CallSignal through E2E encrypted WS)
|
||||
- Room access control (hashed room names, ACL)
|
||||
- Mandatory crypto handshake on all paths
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Content |
|
||||
|----------|---------|
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Full system architecture with Mermaid diagrams |
|
||||
| [TASK_PLAN.md](docs/TASK_PLAN.md) | Phase-by-phase task plan (FC-P1 through P6) |
|
||||
| [PROGRESS.md](docs/PROGRESS.md) | Version history and feature timeline |
|
||||
| [PROTOCOL.md](docs/PROTOCOL.md) | Wire protocol specification |
|
||||
| [SECURITY.md](docs/SECURITY.md) | Threat model and security analysis |
|
||||
| [FUTURE_TASKS.md](docs/FUTURE_TASKS.md) | Backlog with questions-before-starting |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
12
warzone/bots.example.json
Normal file
12
warzone/bots.example.json
Normal file
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{"name": "helpbot", "description": "featherChat help & FAQ"},
|
||||
{"name": "codebot", "description": "Coding assistant"},
|
||||
{"name": "survivalbot", "description": "War/emergency/survival guide"},
|
||||
{"name": "farsibot", "description": "Farsi → English translation"},
|
||||
{"name": "engbot", "description": "English → Farsi translation"},
|
||||
{"name": "mathbot", "description": "Math helper"},
|
||||
{"name": "medbot", "description": "First aid & health info"},
|
||||
{"name": "writebot", "description": "Writing assistant"},
|
||||
{"name": "cookbot", "description": "Cooking with limited ingredients"},
|
||||
{"name": "mindbot", "description": "Mental health & stress support"}
|
||||
]
|
||||
@@ -85,8 +85,16 @@ pub async fn register_with_server_identity(
|
||||
.map_err(|_| anyhow::anyhow!("No bundle found. Run `warzone init` first."))?;
|
||||
let bundle: PreKeyBundle = bincode::deserialize(&bundle_bytes)?;
|
||||
|
||||
// Derive ETH address from seed
|
||||
let eth_address = crate::keystore::load_seed_raw()
|
||||
.map(|seed| {
|
||||
let eth = warzone_protocol::ethereum::derive_eth_identity(&seed);
|
||||
eth.address.to_checksum()
|
||||
})
|
||||
.ok();
|
||||
|
||||
let client = ServerClient::new(server_url);
|
||||
client.register_bundle(&fp, &bundle).await?;
|
||||
client.register_bundle(&fp, &bundle, eth_address).await?;
|
||||
println!("Bundle registered with {}", server_url);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct ServerClient {
|
||||
struct RegisterRequest {
|
||||
fingerprint: String,
|
||||
bundle: Vec<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
eth_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -43,6 +45,7 @@ impl ServerClient {
|
||||
&self,
|
||||
fingerprint: &str,
|
||||
bundle: &PreKeyBundle,
|
||||
eth_address: Option<String>,
|
||||
) -> Result<()> {
|
||||
let encoded =
|
||||
bincode::serialize(bundle).context("failed to serialize bundle")?;
|
||||
@@ -51,6 +54,7 @@ impl ServerClient {
|
||||
.json(&RegisterRequest {
|
||||
fingerprint: fingerprint.to_string(),
|
||||
bundle: encoded,
|
||||
eth_address,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct LocalDb {
|
||||
pre_keys: sled::Tree,
|
||||
contacts: sled::Tree,
|
||||
history: sled::Tree,
|
||||
sender_keys: sled::Tree,
|
||||
_db: sled::Db,
|
||||
}
|
||||
|
||||
@@ -39,11 +40,13 @@ impl LocalDb {
|
||||
let pre_keys = db.open_tree("pre_keys")?;
|
||||
let contacts = db.open_tree("contacts")?;
|
||||
let history = db.open_tree("history")?;
|
||||
let sender_keys = db.open_tree("sender_keys")?;
|
||||
Ok(LocalDb {
|
||||
sessions,
|
||||
pre_keys,
|
||||
contacts,
|
||||
history,
|
||||
sender_keys,
|
||||
_db: db,
|
||||
})
|
||||
}
|
||||
@@ -51,19 +54,28 @@ impl LocalDb {
|
||||
/// Save a ratchet session for a peer.
|
||||
pub fn save_session(&self, peer: &Fingerprint, state: &RatchetState) -> Result<()> {
|
||||
let key = peer.to_hex();
|
||||
let data = bincode::serialize(state).context("failed to serialize session")?;
|
||||
let data = state.serialize_versioned()
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
self.sessions.insert(key.as_bytes(), data)?;
|
||||
self.sessions.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a ratchet session for a peer (used for session recovery).
|
||||
pub fn delete_session(&self, peer: &Fingerprint) -> Result<()> {
|
||||
let key = peer.to_hex();
|
||||
self.sessions.remove(key.as_bytes())?;
|
||||
self.sessions.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a ratchet session for a peer.
|
||||
pub fn load_session(&self, peer: &Fingerprint) -> Result<Option<RatchetState>> {
|
||||
let key = peer.to_hex();
|
||||
match self.sessions.get(key.as_bytes())? {
|
||||
Some(data) => {
|
||||
let state = bincode::deserialize(&data)
|
||||
.context("failed to deserialize session")?;
|
||||
let state = RatchetState::deserialize_versioned(&data)
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
Ok(Some(state))
|
||||
}
|
||||
None => Ok(None),
|
||||
@@ -115,6 +127,39 @@ impl LocalDb {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sender Keys ──
|
||||
|
||||
/// Save a sender key for a (sender, group) pair.
|
||||
pub fn save_sender_key(
|
||||
&self,
|
||||
sender_fp: &str,
|
||||
group_name: &str,
|
||||
key: &warzone_protocol::sender_keys::SenderKey,
|
||||
) -> Result<()> {
|
||||
let db_key = format!("sk:{}:{}", sender_fp, group_name);
|
||||
let data = bincode::serialize(key).context("failed to serialize sender key")?;
|
||||
self.sender_keys.insert(db_key.as_bytes(), data)?;
|
||||
self.sender_keys.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a sender key for a (sender, group) pair.
|
||||
pub fn load_sender_key(
|
||||
&self,
|
||||
sender_fp: &str,
|
||||
group_name: &str,
|
||||
) -> Result<Option<warzone_protocol::sender_keys::SenderKey>> {
|
||||
let db_key = format!("sk:{}:{}", sender_fp, group_name);
|
||||
match self.sender_keys.get(db_key.as_bytes())? {
|
||||
Some(data) => {
|
||||
let key = bincode::deserialize(&data)
|
||||
.context("failed to deserialize sender key")?;
|
||||
Ok(Some(key))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contacts ──
|
||||
|
||||
/// Add or update a contact. Called on send/receive.
|
||||
@@ -228,6 +273,87 @@ impl LocalDb {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create an encrypted backup of all session data.
|
||||
/// Returns the backup file path.
|
||||
pub fn create_backup(&self, seed: &[u8; 32]) -> Result<std::path::PathBuf> {
|
||||
use std::io::Write;
|
||||
|
||||
let backup_dir = crate::keystore::data_dir().join("backups");
|
||||
std::fs::create_dir_all(&backup_dir)?;
|
||||
|
||||
// Collect all data
|
||||
let mut data = serde_json::Map::new();
|
||||
|
||||
// Sessions
|
||||
let mut sessions = serde_json::Map::new();
|
||||
for item in self.sessions.iter() {
|
||||
if let Ok((key, value)) = item {
|
||||
let k = String::from_utf8_lossy(&key).to_string();
|
||||
sessions.insert(k, serde_json::Value::String(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD, &value
|
||||
)));
|
||||
}
|
||||
}
|
||||
data.insert("sessions".into(), serde_json::Value::Object(sessions));
|
||||
|
||||
// Contacts
|
||||
let mut contacts = serde_json::Map::new();
|
||||
for item in self.contacts.iter() {
|
||||
if let Ok((key, value)) = item {
|
||||
let k = String::from_utf8_lossy(&key).to_string();
|
||||
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&value) {
|
||||
contacts.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
data.insert("contacts".into(), serde_json::Value::Object(contacts));
|
||||
|
||||
// Sender keys
|
||||
let mut sender_keys = serde_json::Map::new();
|
||||
for item in self.sender_keys.iter() {
|
||||
if let Ok((key, value)) = item {
|
||||
let k = String::from_utf8_lossy(&key).to_string();
|
||||
sender_keys.insert(k, serde_json::Value::String(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD, &value
|
||||
)));
|
||||
}
|
||||
}
|
||||
data.insert("sender_keys".into(), serde_json::Value::Object(sender_keys));
|
||||
|
||||
// Serialize and encrypt
|
||||
let plaintext = serde_json::to_vec(&serde_json::Value::Object(data))?;
|
||||
let key_bytes = warzone_protocol::crypto::hkdf_derive(seed, b"", b"warzone-backup", 32);
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
let encrypted = warzone_protocol::crypto::aead_encrypt(&key, &plaintext, b"warzone-backup-aad");
|
||||
|
||||
// Write to temp file then rename (atomic)
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let filename = format!("backup_{}.wzbk", timestamp);
|
||||
let path = backup_dir.join(&filename);
|
||||
let tmp_path = backup_dir.join(format!(".{}.tmp", filename));
|
||||
|
||||
let mut file = std::fs::File::create(&tmp_path)?;
|
||||
file.write_all(&encrypted)?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp_path, &path)?;
|
||||
|
||||
// Rotate: keep last 3 backups
|
||||
let mut backups: Vec<_> = std::fs::read_dir(&backup_dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".wzbk"))
|
||||
.collect();
|
||||
backups.sort_by_key(|e| e.file_name());
|
||||
while backups.len() > 3 {
|
||||
if let Some(old) = backups.first() {
|
||||
let _ = std::fs::remove_file(old.path());
|
||||
backups.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Import data from JSON backup (merges, doesn't overwrite existing).
|
||||
pub fn import_all(&self, data: &serde_json::Value) -> Result<usize> {
|
||||
let mut count = 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1212
warzone/crates/warzone-client/src/tui/commands.rs
Normal file
1212
warzone/crates/warzone-client/src/tui/commands.rs
Normal file
File diff suppressed because it is too large
Load Diff
410
warzone/crates/warzone-client/src/tui/draw.rs
Normal file
410
warzone/crates/warzone-client/src/tui/draw.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use chrono::Local;
|
||||
|
||||
use super::types::{App, ReceiptStatus};
|
||||
|
||||
impl App {
|
||||
fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str {
|
||||
match message_id {
|
||||
Some(id) => {
|
||||
let receipts = self.receipts.lock().unwrap();
|
||||
match receipts.get(id) {
|
||||
Some(ReceiptStatus::Read) => " \u{2713}\u{2713}", // ✓✓ (read)
|
||||
Some(ReceiptStatus::Delivered) => " \u{2713}\u{2713}", // ✓✓ (delivered)
|
||||
Some(ReceiptStatus::Sent) | None => " \u{2713}", // ✓ (sent)
|
||||
}
|
||||
}
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn receipt_color(&self, message_id: &Option<String>) -> Color {
|
||||
match message_id {
|
||||
Some(id) => {
|
||||
let receipts = self.receipts.lock().unwrap();
|
||||
match receipts.get(id) {
|
||||
Some(ReceiptStatus::Read) => Color::Blue,
|
||||
Some(ReceiptStatus::Delivered) => Color::White,
|
||||
Some(ReceiptStatus::Sent) | None => Color::DarkGray,
|
||||
}
|
||||
}
|
||||
None => Color::DarkGray,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(&self, frame: &mut Frame) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(5), // messages
|
||||
Constraint::Length(3), // input
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
// Header
|
||||
let peer_str = match (&self.peer_eth, &self.peer_fp) {
|
||||
(Some(eth), _) => format!("{}...", ð[..eth.len().min(12)]),
|
||||
(None, Some(fp)) => fp.clone(),
|
||||
(None, None) => "no peer".to_string(),
|
||||
};
|
||||
let peer_str = peer_str.as_str();
|
||||
let is_connected = self.connected.load(Ordering::Relaxed);
|
||||
let (conn_indicator, conn_color) = if is_connected {
|
||||
(" \u{25CF}", Color::Green) // ●
|
||||
} else {
|
||||
(" \u{25CF}", Color::Red) // ●
|
||||
};
|
||||
let identity_display = if self.our_eth.is_empty() {
|
||||
self.our_fp.clone()
|
||||
} else {
|
||||
format!("{}", &self.our_eth[..self.our_eth.len().min(12)])
|
||||
};
|
||||
// Call indicator
|
||||
let call_span = match &self.call_state {
|
||||
Some(info) => {
|
||||
let label = match info.state {
|
||||
super::types::CallPhase::Calling => format!(" \u{1f4de} Calling {}...", &info.peer_display[..info.peer_display.len().min(12)]),
|
||||
super::types::CallPhase::Ringing => format!(" \u{1f4de} Incoming from {}", &info.peer_display[..info.peer_display.len().min(12)]),
|
||||
super::types::CallPhase::Active => {
|
||||
let elapsed = Local::now().signed_duration_since(info.started_at);
|
||||
let mins = elapsed.num_minutes();
|
||||
let secs = elapsed.num_seconds() % 60;
|
||||
format!(" \u{1f50a} {}:{:02}", mins, secs)
|
||||
}
|
||||
};
|
||||
let color = match info.state {
|
||||
super::types::CallPhase::Calling => Color::Yellow,
|
||||
super::types::CallPhase::Ringing => Color::Magenta,
|
||||
super::types::CallPhase::Active => Color::Green,
|
||||
};
|
||||
Span::styled(label, Style::default().fg(color))
|
||||
}
|
||||
None => Span::raw(""),
|
||||
};
|
||||
let header = Paragraph::new(Line::from(vec![
|
||||
Span::styled("WZ ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(identity_display, Style::default().fg(Color::Green)),
|
||||
Span::raw(" \u{2192} "),
|
||||
Span::styled(peer_str, Style::default().fg(Color::Yellow)),
|
||||
Span::styled(
|
||||
format!(" [{}]", self.server_url),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::styled(conn_indicator, Style::default().fg(conn_color)),
|
||||
call_span,
|
||||
]));
|
||||
frame.render_widget(header, chunks[0]);
|
||||
|
||||
// Messages
|
||||
let msgs = self.messages.lock().unwrap();
|
||||
let items: Vec<ListItem> = msgs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let style = if m.is_system {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else if m.is_self {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default().fg(Color::Yellow)
|
||||
};
|
||||
|
||||
let timestamp = format!("[{}] ", m.timestamp.format("%H:%M"));
|
||||
|
||||
let prefix = if m.is_system {
|
||||
"*** ".to_string()
|
||||
} else {
|
||||
format!("{}: ", &m.sender[..m.sender.len().min(12)])
|
||||
};
|
||||
|
||||
let receipt_str = if m.is_self && m.message_id.is_some() {
|
||||
self.receipt_indicator(&m.message_id)
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let receipt_color = self.receipt_color(&m.message_id);
|
||||
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(timestamp, Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(prefix, style.add_modifier(Modifier::BOLD)),
|
||||
Span::raw(&m.text),
|
||||
Span::styled(receipt_str, Style::default().fg(receipt_color)),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Scroll support: compute the visible window of items
|
||||
let visible_height = chunks[1].height.saturating_sub(1) as usize; // minus top border
|
||||
let total = items.len();
|
||||
let end = total.saturating_sub(self.scroll_offset);
|
||||
let start = end.saturating_sub(visible_height);
|
||||
let visible_items = if total == 0 {
|
||||
vec![]
|
||||
} else {
|
||||
items[start..end].to_vec()
|
||||
};
|
||||
|
||||
let messages_widget = List::new(visible_items)
|
||||
.block(Block::default().borders(Borders::TOP));
|
||||
frame.render_widget(messages_widget, chunks[1]);
|
||||
|
||||
// Input
|
||||
let input_title = if self.scroll_offset > 0 {
|
||||
format!(" [{} new \u{2193}] ", self.scroll_offset)
|
||||
} else {
|
||||
" message ".to_string()
|
||||
};
|
||||
let input_widget = Paragraph::new(self.input.as_str())
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::DarkGray))
|
||||
.title(input_title),
|
||||
)
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(input_widget, chunks[2]);
|
||||
|
||||
// Cursor
|
||||
let x = (self.cursor_pos as u16 + 1).min(chunks[2].width - 2);
|
||||
frame.set_cursor_position((chunks[2].x + x, chunks[2].y + 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use chrono::Local;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use super::super::types::{App, ChatLine};
|
||||
|
||||
/// Helper: collect the entire terminal buffer into a single String.
|
||||
fn full_buffer_text(terminal: &Terminal<TestBackend>) -> String {
|
||||
let buf = terminal.backend().buffer();
|
||||
(0..buf.area().height)
|
||||
.flat_map(|y| {
|
||||
(0..buf.area().width).map(move |x| {
|
||||
buf.cell((x, y))
|
||||
.map(|c| c.symbol().chars().next().unwrap_or(' '))
|
||||
.unwrap_or(' ')
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Helper: check whether the buffer contains `needle`.
|
||||
fn buffer_contains(terminal: &Terminal<TestBackend>, needle: &str) -> bool {
|
||||
full_buffer_text(terminal).contains(needle)
|
||||
}
|
||||
|
||||
/// Helper: collect a single row into a String.
|
||||
fn row_text(terminal: &Terminal<TestBackend>, row: u16) -> String {
|
||||
let buf = terminal.backend().buffer();
|
||||
(0..buf.area().width)
|
||||
.map(|x| {
|
||||
buf.cell((x, row))
|
||||
.map(|c| c.symbol().chars().next().unwrap_or(' '))
|
||||
.unwrap_or(' ')
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn make_app() -> App {
|
||||
App::new("aabbcc".into(), Some("ddeeff".into()), "localhost:7700".into())
|
||||
}
|
||||
|
||||
fn make_terminal() -> Terminal<TestBackend> {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
Terminal::new(backend).expect("terminal creation should succeed")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 1. draw_does_not_panic
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn draw_does_not_panic() {
|
||||
let app = make_app();
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).expect("draw should not fail");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 2. header_contains_fingerprint
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn header_contains_identity() {
|
||||
let app = make_app();
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
let header = row_text(&terminal, 0);
|
||||
// Header shows ETH address (if seed exists) or fingerprint
|
||||
assert!(
|
||||
header.contains("aabbcc") || header.contains("0x"),
|
||||
"header should contain fingerprint or ETH address, got: {header}"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 3. connection_indicator_red_when_disconnected
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn connection_indicator_red_when_disconnected() {
|
||||
let app = make_app();
|
||||
// connected defaults to false
|
||||
assert!(!app.connected.load(Ordering::Relaxed));
|
||||
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
let header = row_text(&terminal, 0);
|
||||
assert!(
|
||||
header.contains('\u{25CF}'),
|
||||
"header should contain the dot character when disconnected, got: {header}"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 4. connection_indicator_green_when_connected
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn connection_indicator_green_when_connected() {
|
||||
let app = make_app();
|
||||
app.connected.store(true, Ordering::Relaxed);
|
||||
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
let header = row_text(&terminal, 0);
|
||||
assert!(
|
||||
header.contains('\u{25CF}'),
|
||||
"header should contain the dot character when connected, got: {header}"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 5. timestamp_format_in_messages
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn timestamp_format_in_messages() {
|
||||
let app = make_app();
|
||||
app.add_message(ChatLine {
|
||||
sender: "alice".into(),
|
||||
text: "hello world".into(),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
let text = full_buffer_text(&terminal);
|
||||
// Timestamps are rendered as [HH:MM] — look for the bracket pattern.
|
||||
assert!(
|
||||
text.contains('[') && text.contains(']'),
|
||||
"buffer should contain timestamp brackets, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 6. scroll_offset_zero_shows_latest_messages
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn scroll_offset_zero_shows_latest_messages() {
|
||||
let app = make_app();
|
||||
for i in 0..30 {
|
||||
app.add_message(ChatLine {
|
||||
sender: "bot".into(),
|
||||
text: format!("msg-{i:03}"),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
// scroll_offset defaults to 0 — pinned to bottom.
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
assert!(
|
||||
buffer_contains(&terminal, "msg-029"),
|
||||
"the last message should be visible when scroll_offset is 0"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 7. scroll_offset_hides_latest_messages
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn scroll_offset_hides_latest_messages() {
|
||||
let mut app = make_app();
|
||||
for i in 0..30 {
|
||||
app.add_message(ChatLine {
|
||||
sender: "bot".into(),
|
||||
text: format!("msg-{i:03}"),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
app.scroll_offset = 10;
|
||||
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
assert!(
|
||||
!buffer_contains(&terminal, "msg-029"),
|
||||
"the last message should NOT be visible when scroll_offset=10"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 8. unread_badge_shows_when_scrolled
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn unread_badge_shows_when_scrolled() {
|
||||
let mut app = make_app();
|
||||
app.scroll_offset = 5;
|
||||
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
assert!(
|
||||
buffer_contains(&terminal, "new"),
|
||||
"buffer should contain 'new' from the unread badge when scrolled"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 9. no_unread_badge_at_bottom
|
||||
// ----------------------------------------------------------------
|
||||
#[test]
|
||||
fn no_unread_badge_at_bottom() {
|
||||
let app = make_app();
|
||||
// scroll_offset is 0 by default
|
||||
|
||||
let mut terminal = make_terminal();
|
||||
terminal.draw(|f| app.draw(f)).unwrap();
|
||||
|
||||
assert!(
|
||||
buffer_contains(&terminal, "message"),
|
||||
"buffer should contain the default title 'message' when not scrolled"
|
||||
);
|
||||
assert!(
|
||||
!full_buffer_text(&terminal).contains("new \u{2193}"),
|
||||
"buffer should NOT contain 'new ↓' when scroll_offset is 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
292
warzone/crates/warzone-client/src/tui/file_transfer.rs
Normal file
292
warzone/crates/warzone-client/src/tui/file_transfer.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::{Sha256, Digest};
|
||||
use warzone_protocol::identity::IdentityKeyPair;
|
||||
use warzone_protocol::message::WireMessage;
|
||||
use warzone_protocol::types::Fingerprint;
|
||||
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
use chrono::Local;
|
||||
|
||||
use super::types::{App, ChatLine, normfp, MAX_FILE_SIZE, CHUNK_SIZE};
|
||||
|
||||
impl App {
|
||||
pub async fn handle_file_send(
|
||||
&mut self,
|
||||
path_str: &str,
|
||||
identity: &IdentityKeyPair,
|
||||
db: &LocalDb,
|
||||
client: &ServerClient,
|
||||
) {
|
||||
let path = PathBuf::from(path_str);
|
||||
if !path.exists() {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("File not found: {}", path_str),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let metadata = match std::fs::metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Cannot read file: {}", e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let file_size = metadata.len();
|
||||
if file_size > MAX_FILE_SIZE {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("File too large: {} bytes (max {} bytes)", file_size, MAX_FILE_SIZE),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let file_data = match std::fs::read(&path) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Failed to read file: {}", e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let filename = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unnamed".to_string());
|
||||
|
||||
// SHA-256 of the complete file
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&file_data);
|
||||
let sha256 = format!("{:x}", hasher.finalize());
|
||||
|
||||
let file_id = uuid::Uuid::new_v4().to_string();
|
||||
let total_chunks = ((file_data.len() + CHUNK_SIZE - 1) / CHUNK_SIZE) as u32;
|
||||
|
||||
// Resolve peer (or group members)
|
||||
let peer = match &self.peer_fp {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Set a peer or group first".into(),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Group file transfer: send to each member
|
||||
if peer.starts_with('#') {
|
||||
let group_name = &peer[1..];
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Sending '{}' to group #{}...", filename, group_name),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
// Get members
|
||||
let url = format!("{}/v1/groups/{}", client.base_url, group_name);
|
||||
let group_data = match client.client.get(&url).send().await {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>().await {
|
||||
Ok(d) => d,
|
||||
Err(_) => return,
|
||||
},
|
||||
Err(_) => return,
|
||||
};
|
||||
let my_fp = normfp(&self.our_fp);
|
||||
let members: Vec<String> = group_data.get("members")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
for member in &members {
|
||||
if *member == my_fp { continue; }
|
||||
// Send file header + chunks to each member via HTTP
|
||||
let header = WireMessage::FileHeader {
|
||||
id: file_id.clone(),
|
||||
sender_fingerprint: self.our_fp.clone(),
|
||||
filename: filename.clone(),
|
||||
file_size,
|
||||
total_chunks,
|
||||
sha256: sha256.clone(),
|
||||
};
|
||||
if let Ok(encoded) = bincode::serialize(&header) {
|
||||
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
|
||||
}
|
||||
for i in 0..total_chunks {
|
||||
let start = i as usize * CHUNK_SIZE;
|
||||
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
|
||||
let chunk_msg = WireMessage::FileChunk {
|
||||
id: file_id.clone(),
|
||||
sender_fingerprint: self.our_fp.clone(),
|
||||
filename: filename.clone(),
|
||||
chunk_index: i,
|
||||
total_chunks,
|
||||
data: file_data[start..end].to_vec(),
|
||||
};
|
||||
if let Ok(encoded) = bincode::serialize(&chunk_msg) {
|
||||
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("File '{}' sent to group #{}", filename, group_name),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
let peer_fp = match Fingerprint::from_hex(&peer) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Invalid peer fingerprint".into(),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let our_pub = identity.public_identity();
|
||||
let our_fp_str = our_pub.fingerprint.to_string();
|
||||
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Sending file '{}' ({} bytes, {} chunks)...", filename, file_size, total_chunks),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
// Send FileHeader (unencrypted metadata — the chunks carry ratchet-encrypted data)
|
||||
let header = WireMessage::FileHeader {
|
||||
id: file_id.clone(),
|
||||
sender_fingerprint: our_fp_str.clone(),
|
||||
filename: filename.clone(),
|
||||
file_size,
|
||||
total_chunks,
|
||||
sha256: sha256.clone(),
|
||||
};
|
||||
|
||||
let encoded_header = match bincode::serialize(&header) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Serialize header failed: {}", e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded_header).await {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Failed to send file header: {}", e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Send each chunk: encrypt chunk data with ratchet, wrap in FileChunk
|
||||
for i in 0..total_chunks {
|
||||
let start = i as usize * CHUNK_SIZE;
|
||||
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
|
||||
let chunk_data = &file_data[start..end];
|
||||
|
||||
// Encrypt chunk data with ratchet
|
||||
let mut ratchet = db.load_session(&peer_fp).ok().flatten();
|
||||
let encrypted_data = if let Some(ref mut state) = ratchet {
|
||||
match state.encrypt(chunk_data) {
|
||||
Ok(encrypted) => {
|
||||
let _ = db.save_session(&peer_fp, state);
|
||||
match bincode::serialize(&encrypted) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Serialize chunk failed: {}", e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Encrypt chunk {} failed: {}", i, e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "No ratchet session. Send a text message first to establish one.".into(),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
let chunk_msg = WireMessage::FileChunk {
|
||||
id: file_id.clone(),
|
||||
sender_fingerprint: our_fp_str.clone(),
|
||||
filename: filename.clone(),
|
||||
chunk_index: i,
|
||||
total_chunks,
|
||||
data: encrypted_data,
|
||||
};
|
||||
|
||||
let encoded = match bincode::serialize(&chunk_msg) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Serialize chunk {} failed: {}", i, e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded).await {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Failed to send chunk {}/{}: {}", i + 1, total_chunks, e),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Sent chunk [{}/{}] of {}", i + 1, total_chunks, filename),
|
||||
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
|
||||
self.add_message(ChatLine {
|
||||
sender: self.our_fp[..12.min(self.our_fp.len())].to_string(),
|
||||
text: format!("Sent file: {} ({} bytes)", filename, file_size),
|
||||
is_system: false, is_self: true, message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
377
warzone/crates/warzone-client/src/tui/input.rs
Normal file
377
warzone/crates/warzone-client/src/tui/input.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use super::types::App;
|
||||
|
||||
impl App {
|
||||
/// Handle a single key event. Returns true if the event was consumed.
|
||||
pub fn handle_key_event(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
// Alt+Backspace: delete word before cursor
|
||||
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
if self.cursor_pos > 0 {
|
||||
let before = &self.input[..self.cursor_pos];
|
||||
let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
|
||||
self.input.drain(new_pos..self.cursor_pos);
|
||||
self.cursor_pos = new_pos;
|
||||
}
|
||||
}
|
||||
// Backspace: delete char before cursor
|
||||
KeyCode::Backspace => {
|
||||
if self.cursor_pos > 0 {
|
||||
self.input.remove(self.cursor_pos - 1);
|
||||
self.cursor_pos -= 1;
|
||||
}
|
||||
}
|
||||
// Delete: delete char at cursor
|
||||
KeyCode::Delete => {
|
||||
if self.cursor_pos < self.input.len() {
|
||||
self.input.remove(self.cursor_pos);
|
||||
}
|
||||
}
|
||||
// Left arrow
|
||||
KeyCode::Left => {
|
||||
if key.modifiers.contains(KeyModifiers::ALT) {
|
||||
// Alt+Left: word left
|
||||
let before = &self.input[..self.cursor_pos];
|
||||
self.cursor_pos = before.rfind(' ').unwrap_or(0);
|
||||
} else if self.cursor_pos > 0 {
|
||||
self.cursor_pos -= 1;
|
||||
}
|
||||
}
|
||||
// Right arrow
|
||||
KeyCode::Right => {
|
||||
if key.modifiers.contains(KeyModifiers::ALT) {
|
||||
// Alt+Right: word right
|
||||
let after = &self.input[self.cursor_pos..];
|
||||
self.cursor_pos += after.find(' ').map(|i| i + 1).unwrap_or(after.len());
|
||||
} else if self.cursor_pos < self.input.len() {
|
||||
self.cursor_pos += 1;
|
||||
}
|
||||
}
|
||||
// Home / Ctrl+A / Cmd+A (macOS)
|
||||
KeyCode::Home => { self.cursor_pos = 0; }
|
||||
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => {
|
||||
self.cursor_pos = 0;
|
||||
}
|
||||
// End: cursor to end of input when typing, snap to bottom when input is empty.
|
||||
// Ctrl+End always snaps to bottom.
|
||||
KeyCode::End => {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
// Ctrl+End: always snap scroll to bottom
|
||||
self.scroll_offset = 0;
|
||||
} else if self.input.is_empty() {
|
||||
// Plain End with empty input: snap scroll to bottom
|
||||
self.scroll_offset = 0;
|
||||
} else {
|
||||
// Plain End with text: move cursor to end of input
|
||||
self.cursor_pos = self.input.len();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => {
|
||||
self.cursor_pos = self.input.len();
|
||||
}
|
||||
// Ctrl+U / Cmd+U: clear line
|
||||
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => {
|
||||
self.input.clear();
|
||||
self.cursor_pos = 0;
|
||||
}
|
||||
// Ctrl+K / Cmd+K: kill to end of line
|
||||
KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => {
|
||||
self.input.truncate(self.cursor_pos);
|
||||
}
|
||||
// Ctrl+W / Cmd+W: delete word back
|
||||
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER) => {
|
||||
let before = &self.input[..self.cursor_pos];
|
||||
let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
|
||||
self.input.drain(new_pos..self.cursor_pos);
|
||||
self.cursor_pos = new_pos;
|
||||
}
|
||||
// PageUp: scroll up by 10 messages
|
||||
KeyCode::PageUp => {
|
||||
let max = self.messages.lock().unwrap().len().saturating_sub(1);
|
||||
self.scroll_offset = (self.scroll_offset + 10).min(max);
|
||||
}
|
||||
// PageDown: scroll down by 10 messages
|
||||
KeyCode::PageDown => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(10);
|
||||
}
|
||||
// Up arrow: scroll up by 1 (only when input is empty)
|
||||
KeyCode::Up if self.input.is_empty() => {
|
||||
let max = self.messages.lock().unwrap().len().saturating_sub(1);
|
||||
self.scroll_offset = (self.scroll_offset + 1).min(max);
|
||||
}
|
||||
// Down arrow: scroll down by 1 (only when input is empty)
|
||||
KeyCode::Down if self.input.is_empty() => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
// Regular char: insert at cursor
|
||||
KeyCode::Char(c) => {
|
||||
self.input.insert(self.cursor_pos, c);
|
||||
self.cursor_pos += 1;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::tui::types::App;
|
||||
|
||||
/// Helper: create a key event with no modifiers.
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
/// Helper: create a key event with modifiers.
|
||||
fn key_mod(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent::new(code, modifiers)
|
||||
}
|
||||
|
||||
/// Helper: create a fresh App for testing.
|
||||
fn app() -> App {
|
||||
App::new("aabbcc".into(), None, "http://localhost:7700".into())
|
||||
}
|
||||
|
||||
/// Helper: type a string into the app one character at a time.
|
||||
fn type_str(app: &mut App, s: &str) {
|
||||
for c in s.chars() {
|
||||
app.handle_key_event(key(KeyCode::Char(c)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text editing tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn char_insert() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
assert_eq!(app.input, "abc");
|
||||
assert_eq!(app.cursor_pos, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_char() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Backspace));
|
||||
assert_eq!(app.input, "ab");
|
||||
assert_eq!(app.cursor_pos, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_at_start_does_nothing() {
|
||||
let mut app = app();
|
||||
assert!(app.input.is_empty());
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
app.handle_key_event(key(KeyCode::Backspace));
|
||||
assert!(app.input.is_empty());
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_at_cursor() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Left));
|
||||
app.handle_key_event(key(KeyCode::Delete));
|
||||
assert_eq!(app.input, "ab");
|
||||
assert_eq!(app.cursor_pos, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_u_clears_line() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "hello");
|
||||
app.handle_key_event(key_mod(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
assert!(app.input.is_empty());
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_k_kills_to_end() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "hello");
|
||||
app.handle_key_event(key(KeyCode::Home));
|
||||
app.handle_key_event(key(KeyCode::Right));
|
||||
app.handle_key_event(key(KeyCode::Right));
|
||||
app.handle_key_event(key_mod(KeyCode::Char('k'), KeyModifiers::CONTROL));
|
||||
assert_eq!(app.input, "he");
|
||||
assert_eq!(app.cursor_pos, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_w_deletes_word() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "hello world");
|
||||
app.handle_key_event(key_mod(KeyCode::Char('w'), KeyModifiers::CONTROL));
|
||||
assert_eq!(app.input, "hello ");
|
||||
assert_eq!(app.cursor_pos, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_backspace_deletes_word() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "hello world");
|
||||
app.handle_key_event(key_mod(KeyCode::Backspace, KeyModifiers::ALT));
|
||||
assert_eq!(app.input, "hello ");
|
||||
assert_eq!(app.cursor_pos, 6);
|
||||
}
|
||||
|
||||
// ── Cursor movement tests ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn left_arrow_moves_cursor() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Left));
|
||||
assert_eq!(app.cursor_pos, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_arrow_moves_cursor() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Home));
|
||||
app.handle_key_event(key(KeyCode::Right));
|
||||
assert_eq!(app.cursor_pos, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_moves_to_start() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Home));
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_moves_to_end() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Home));
|
||||
app.handle_key_event(key(KeyCode::End));
|
||||
assert_eq!(app.cursor_pos, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_a_moves_to_start() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key_mod(KeyCode::Char('a'), KeyModifiers::CONTROL));
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_e_moves_to_end() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "abc");
|
||||
app.handle_key_event(key(KeyCode::Home));
|
||||
app.handle_key_event(key_mod(KeyCode::Char('e'), KeyModifiers::CONTROL));
|
||||
assert_eq!(app.cursor_pos, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_at_start_does_nothing() {
|
||||
let mut app = app();
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
app.handle_key_event(key(KeyCode::Left));
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
}
|
||||
|
||||
// ── Quit tests ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_quits() {
|
||||
let mut app = app();
|
||||
app.handle_key_event(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
assert!(app.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_quits() {
|
||||
let mut app = app();
|
||||
app.handle_key_event(key(KeyCode::Esc));
|
||||
assert!(app.should_quit);
|
||||
}
|
||||
|
||||
// ── Scroll tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn page_up_increases_scroll_offset() {
|
||||
let mut app = app();
|
||||
// App::new creates 3 system messages, so max = 3 - 1 = 2
|
||||
let msg_count = app.messages.lock().unwrap().len();
|
||||
app.handle_key_event(key(KeyCode::PageUp));
|
||||
// scroll_offset = min(10, msg_count - 1)
|
||||
let expected = 10usize.min(msg_count.saturating_sub(1));
|
||||
assert_eq!(app.scroll_offset, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_down_decreases_scroll_offset() {
|
||||
let mut app = app();
|
||||
app.scroll_offset = 15;
|
||||
app.handle_key_event(key(KeyCode::PageDown));
|
||||
assert_eq!(app.scroll_offset, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_down_clamps_to_zero() {
|
||||
let mut app = app();
|
||||
app.scroll_offset = 3;
|
||||
app.handle_key_event(key(KeyCode::PageDown));
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_arrow_scrolls_when_input_empty() {
|
||||
let mut app = app();
|
||||
assert!(app.input.is_empty());
|
||||
app.handle_key_event(key(KeyCode::Up));
|
||||
assert_eq!(app.scroll_offset, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_arrow_ignored_when_input_not_empty() {
|
||||
let mut app = app();
|
||||
type_str(&mut app, "hi");
|
||||
app.handle_key_event(key(KeyCode::Up));
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_arrow_scrolls_when_input_empty() {
|
||||
let mut app = app();
|
||||
app.scroll_offset = 5;
|
||||
assert!(app.input.is_empty());
|
||||
app.handle_key_event(key(KeyCode::Down));
|
||||
assert_eq!(app.scroll_offset, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn down_arrow_at_zero_stays_zero() {
|
||||
let mut app = app();
|
||||
assert!(app.input.is_empty());
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
app.handle_key_event(key(KeyCode::Down));
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_snaps_to_bottom_when_input_empty() {
|
||||
let mut app = app();
|
||||
app.scroll_offset = 10;
|
||||
assert!(app.input.is_empty());
|
||||
app.handle_key_event(key(KeyCode::End));
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,128 @@
|
||||
pub mod app;
|
||||
mod types;
|
||||
mod draw;
|
||||
mod commands;
|
||||
mod file_transfer;
|
||||
mod input;
|
||||
mod network;
|
||||
|
||||
pub use app::run_tui;
|
||||
pub use types::App;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyCode};
|
||||
|
||||
use warzone_protocol::identity::{IdentityKeyPair, Seed};
|
||||
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
/// Run the TUI event loop.
|
||||
pub async fn run_tui(
|
||||
our_fp: String,
|
||||
peer_fp: Option<String>,
|
||||
server_url: String,
|
||||
identity: IdentityKeyPair,
|
||||
poll_seed: Seed,
|
||||
db: LocalDb,
|
||||
) -> Result<()> {
|
||||
let mut terminal = ratatui::init();
|
||||
let client = ServerClient::new(&server_url);
|
||||
let db = Arc::new(db);
|
||||
|
||||
let mut app = App::new(our_fp.clone(), peer_fp, server_url);
|
||||
|
||||
// Derive a second identity for the poll loop (can't clone IdentityKeyPair)
|
||||
let poll_identity = poll_seed.derive_identity();
|
||||
let poll_messages = app.messages.clone();
|
||||
let poll_receipts = app.receipts.clone();
|
||||
let poll_pending_files = app.pending_files.clone();
|
||||
let poll_last_dm = app.last_dm_peer.clone();
|
||||
let poll_connected = app.connected.clone();
|
||||
let poll_client = client.clone();
|
||||
let poll_db = db.clone();
|
||||
let poll_fp = our_fp.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
network::poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm, poll_connected).await;
|
||||
});
|
||||
|
||||
// Spawn periodic backup task (every 5 minutes)
|
||||
{
|
||||
let backup_db = db.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Ok(seed) = crate::keystore::load_seed_raw() {
|
||||
match backup_db.create_backup(&seed) {
|
||||
Ok(path) => tracing::debug!("Auto-backup created: {}", path.display()),
|
||||
Err(e) => tracing::warn!("Auto-backup failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-join #ops if no peer set (create if needed)
|
||||
if app.peer_fp.is_none() {
|
||||
let fp_clean: String = our_fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase();
|
||||
// Create #ops if it doesn't exist
|
||||
let _ = client.client.post(format!("{}/v1/groups/create", client.base_url))
|
||||
.json(&serde_json::json!({"name": "ops", "creator": fp_clean}))
|
||||
.send().await;
|
||||
// Join
|
||||
let _ = client.client.post(format!("{}/v1/groups/ops/join", client.base_url))
|
||||
.json(&serde_json::json!({"fingerprint": fp_clean}))
|
||||
.send().await;
|
||||
app.peer_fp = Some("#ops".to_string());
|
||||
app.add_message(types::ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Welcome! You have been added to #ops".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: chrono::Local::now(),
|
||||
});
|
||||
|
||||
// Show system bots
|
||||
if let Ok(resp) = client.client.get(format!("{}/v1/bot/list", client.base_url)).send().await {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(bots) = data.get("bots").and_then(|v| v.as_array()) {
|
||||
if !bots.is_empty() {
|
||||
app.add_message(types::ChatLine { sender: "system".into(), text: "Available bots:".into(), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() });
|
||||
for b in bots {
|
||||
let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let desc = b.get("description").and_then(|v| v.as_str()).unwrap_or("");
|
||||
app.add_message(types::ChatLine { sender: "system".into(), text: format!(" @{} — {}", name, desc), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() });
|
||||
}
|
||||
app.add_message(types::ChatLine { sender: "system".into(), text: "Message a bot: /peer @botname".into(), is_system: true, is_self: false, message_id: None, timestamp: chrono::Local::now() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
terminal.draw(|frame| app.draw(frame))?;
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.code == KeyCode::Enter {
|
||||
app.handle_send(&identity, &db, &client).await;
|
||||
app.scroll_offset = 0;
|
||||
} else {
|
||||
app.handle_key_event(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ratatui::restore();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
674
warzone/crates/warzone-client/src/tui/network.rs
Normal file
674
warzone/crates/warzone-client/src/tui/network.rs
Normal file
@@ -0,0 +1,674 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use sha2::{Sha256, Digest};
|
||||
use warzone_protocol::identity::IdentityKeyPair;
|
||||
use warzone_protocol::message::{ReceiptType, WireMessage};
|
||||
use warzone_protocol::ratchet::RatchetState;
|
||||
use warzone_protocol::types::Fingerprint;
|
||||
use warzone_protocol::x3dh;
|
||||
use x25519_dalek::PublicKey;
|
||||
|
||||
use crate::net::ServerClient;
|
||||
use crate::storage::LocalDb;
|
||||
|
||||
use chrono::Local;
|
||||
|
||||
use super::types::{ChatLine, PendingFileTransfer, ReceiptStatus, normfp};
|
||||
|
||||
/// Send a delivery receipt for a message back to its sender.
|
||||
fn send_receipt(
|
||||
our_fp: &str,
|
||||
sender_fp: &str,
|
||||
message_id: &str,
|
||||
receipt_type: ReceiptType,
|
||||
client: &ServerClient,
|
||||
) {
|
||||
let receipt = WireMessage::Receipt {
|
||||
sender_fingerprint: our_fp.to_string(),
|
||||
message_id: message_id.to_string(),
|
||||
receipt_type,
|
||||
};
|
||||
let encoded = match bincode::serialize(&receipt) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
let client = client.clone();
|
||||
let to = sender_fp.to_string();
|
||||
let from = our_fp.to_string();
|
||||
tokio::spawn(async move {
|
||||
let _ = client.send_message(&to, Some(&from), &encoded).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// ETH address cache: fingerprint → ETH address (populated async, read sync).
|
||||
pub type EthCache = Arc<std::sync::Mutex<HashMap<String, String>>>;
|
||||
|
||||
/// Display a fingerprint as short ETH address if cached, otherwise truncated fingerprint.
|
||||
fn display_sender(fp: &str, eth_cache: &EthCache) -> String {
|
||||
let cache = eth_cache.lock().unwrap();
|
||||
if let Some(eth) = cache.get(fp) {
|
||||
format!("{}...", ð[..eth.len().min(12)])
|
||||
} else {
|
||||
fp[..fp.len().min(12)].to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Async: look up ETH address for a fingerprint and cache it.
|
||||
fn cache_eth_lookup(fp: &str, client: &ServerClient, eth_cache: &EthCache) {
|
||||
let fp = fp.to_string();
|
||||
let client = client.clone();
|
||||
let cache = eth_cache.clone();
|
||||
// Check if already cached
|
||||
if cache.lock().unwrap().contains_key(&fp) { return; }
|
||||
tokio::spawn(async move {
|
||||
let url = format!("{}/v1/resolve/{}", client.base_url, fp);
|
||||
if let Ok(resp) = client.client.get(&url).send().await {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(eth) = data.get("eth_address").and_then(|v| v.as_str()) {
|
||||
cache.lock().unwrap().insert(fp, eth.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn store_received(db: &LocalDb, sender_fp: &str, text: &str) {
|
||||
let _ = db.touch_contact(sender_fp, None);
|
||||
let _ = db.store_message(sender_fp, sender_fp, text, false);
|
||||
}
|
||||
|
||||
/// Process a single incoming raw message (shared by WS and HTTP paths).
|
||||
pub fn process_incoming(
|
||||
raw: &[u8],
|
||||
identity: &IdentityKeyPair,
|
||||
db: &LocalDb,
|
||||
messages: &Arc<Mutex<Vec<ChatLine>>>,
|
||||
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||
our_fp: &str,
|
||||
client: &ServerClient,
|
||||
eth_cache: &EthCache,
|
||||
last_dm_peer: &Arc<Mutex<Option<String>>>,
|
||||
) {
|
||||
match warzone_protocol::message::deserialize_envelope(raw) {
|
||||
Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer, eth_cache),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn process_wire_message(
|
||||
wire: WireMessage,
|
||||
identity: &IdentityKeyPair,
|
||||
db: &LocalDb,
|
||||
messages: &Arc<Mutex<Vec<ChatLine>>>,
|
||||
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||
our_fp: &str,
|
||||
client: &ServerClient,
|
||||
last_dm_peer: &Arc<Mutex<Option<String>>>,
|
||||
eth_cache: &EthCache,
|
||||
) {
|
||||
match wire {
|
||||
WireMessage::KeyExchange {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
sender_identity_encryption_key,
|
||||
ephemeral_public,
|
||||
used_one_time_pre_key_id,
|
||||
ratchet_message,
|
||||
} => {
|
||||
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => return,
|
||||
};
|
||||
let spk_secret = match db.load_signed_pre_key(1) {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return,
|
||||
};
|
||||
let otpk_secret = if let Some(otpk_id) = used_one_time_pre_key_id {
|
||||
db.take_one_time_pre_key(otpk_id).ok().flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let their_id_x25519 = PublicKey::from(sender_identity_encryption_key);
|
||||
let their_eph = PublicKey::from(ephemeral_public);
|
||||
let shared_secret = match x3dh::respond(
|
||||
identity, &spk_secret, otpk_secret.as_ref(), &their_id_x25519, &their_eph,
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut state = RatchetState::init_bob(shared_secret, spk_secret);
|
||||
match state.decrypt(&ratchet_message) {
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||
let _ = db.save_session(&sender_fp, &state);
|
||||
if normfp(&sender_fingerprint) != normfp(our_fp) {
|
||||
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
|
||||
}
|
||||
store_received(db, &sender_fingerprint, &text);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) },
|
||||
text,
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
|
||||
// Terminal bell for incoming DM
|
||||
print!("\x07");
|
||||
}
|
||||
Err(e) => {
|
||||
// Session auto-recovery: delete corrupted session, show warning
|
||||
let _ = db.delete_session(&sender_fp);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)]
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
WireMessage::Message {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
ratchet_message,
|
||||
} => {
|
||||
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut state = match db.load_session(&sender_fp) {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return,
|
||||
};
|
||||
match state.decrypt(&ratchet_message) {
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||
let _ = db.save_session(&sender_fp, &state);
|
||||
if normfp(&sender_fingerprint) != normfp(our_fp) {
|
||||
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
|
||||
}
|
||||
store_received(db, &sender_fingerprint, &text);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) },
|
||||
text,
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
|
||||
// Terminal bell for incoming DM
|
||||
print!("\x07");
|
||||
}
|
||||
Err(e) => {
|
||||
// Session auto-recovery: delete corrupted session, show warning
|
||||
let _ = db.delete_session(&sender_fp);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)]
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
WireMessage::Receipt {
|
||||
sender_fingerprint: _,
|
||||
message_id,
|
||||
receipt_type,
|
||||
} => {
|
||||
// Update receipt status for the referenced message
|
||||
let mut r = receipts.lock().unwrap();
|
||||
let current = r.get(&message_id);
|
||||
let should_update = match (&receipt_type, current) {
|
||||
(ReceiptType::Read, _) => true,
|
||||
(ReceiptType::Delivered, Some(ReceiptStatus::Sent)) => true,
|
||||
(ReceiptType::Delivered, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if should_update {
|
||||
let new_status = match receipt_type {
|
||||
ReceiptType::Delivered => ReceiptStatus::Delivered,
|
||||
ReceiptType::Read => ReceiptStatus::Read,
|
||||
};
|
||||
r.insert(message_id, new_status);
|
||||
}
|
||||
}
|
||||
WireMessage::FileHeader {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
filename,
|
||||
file_size,
|
||||
total_chunks,
|
||||
sha256,
|
||||
} => {
|
||||
let short_sender = &sender_fingerprint[..sender_fingerprint.len().min(12)];
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"Incoming file '{}' from {} ({} bytes, {} chunks)",
|
||||
filename, short_sender, file_size, total_chunks
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
let transfer = PendingFileTransfer {
|
||||
filename,
|
||||
total_chunks,
|
||||
received: 0,
|
||||
chunks: vec![None; total_chunks as usize],
|
||||
sha256,
|
||||
file_size,
|
||||
};
|
||||
pending_files.lock().unwrap().insert(id, transfer);
|
||||
}
|
||||
WireMessage::FileChunk {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
filename: _,
|
||||
chunk_index,
|
||||
total_chunks: _,
|
||||
data,
|
||||
} => {
|
||||
// Decrypt the chunk data using our ratchet session with the sender
|
||||
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut state = match db.load_session(&sender_fp) {
|
||||
Ok(Some(s)) => s,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// The data field is a bincode-serialized RatchetMessage
|
||||
let ratchet_msg = match bincode::deserialize(&data) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let plaintext = match state.decrypt(&ratchet_msg) {
|
||||
Ok(pt) => {
|
||||
let _ = db.save_session(&sender_fp, &state);
|
||||
pt
|
||||
}
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut pf = pending_files.lock().unwrap();
|
||||
if let Some(transfer) = pf.get_mut(&id) {
|
||||
if (chunk_index as usize) < transfer.chunks.len() {
|
||||
if transfer.chunks[chunk_index as usize].is_none() {
|
||||
transfer.chunks[chunk_index as usize] = Some(plaintext);
|
||||
transfer.received += 1;
|
||||
}
|
||||
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"Receiving {} [{}/{}]...",
|
||||
transfer.filename, transfer.received, transfer.total_chunks
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
// Check if all chunks received
|
||||
if transfer.received == transfer.total_chunks {
|
||||
let mut assembled = Vec::with_capacity(transfer.file_size as usize);
|
||||
for chunk in &transfer.chunks {
|
||||
if let Some(data) = chunk {
|
||||
assembled.extend_from_slice(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify SHA-256
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&assembled);
|
||||
let computed_hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
if computed_hash != transfer.sha256 {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"File '{}' integrity check FAILED (hash mismatch)",
|
||||
transfer.filename
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
} else {
|
||||
// Save to data_dir/downloads/
|
||||
let download_dir = crate::keystore::data_dir().join("downloads");
|
||||
let _ = std::fs::create_dir_all(&download_dir);
|
||||
let save_path = download_dir.join(&transfer.filename);
|
||||
match std::fs::write(&save_path, &assembled) {
|
||||
Ok(_) => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"File saved: {}",
|
||||
save_path.display()
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Failed to save file: {}", e),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove completed transfer
|
||||
pf.remove(&id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Received chunk without header — ignore
|
||||
}
|
||||
}
|
||||
WireMessage::GroupSenderKey {
|
||||
id: _,
|
||||
sender_fingerprint,
|
||||
group_name,
|
||||
generation,
|
||||
counter,
|
||||
ciphertext,
|
||||
} => {
|
||||
match db.load_sender_key(&sender_fingerprint, &group_name) {
|
||||
Ok(Some(mut sender_key)) => {
|
||||
let msg = warzone_protocol::sender_keys::SenderKeyMessage {
|
||||
sender_fingerprint: sender_fingerprint.clone(),
|
||||
group_name: group_name.clone(),
|
||||
generation,
|
||||
counter,
|
||||
ciphertext,
|
||||
};
|
||||
match sender_key.decrypt(&msg) {
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||
// Save updated sender key (counter advanced)
|
||||
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
|
||||
store_received(db, &sender_fingerprint, &text);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: format!(
|
||||
"{} [#{}]",
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
group_name
|
||||
),
|
||||
text,
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"[group #{}] decrypt failed from {}: {}",
|
||||
group_name,
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
e
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"[group #{}] no sender key for {} — key distribution needed",
|
||||
group_name,
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)]
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
WireMessage::SenderKeyDistribution {
|
||||
sender_fingerprint,
|
||||
group_name,
|
||||
chain_key,
|
||||
generation,
|
||||
} => {
|
||||
let dist = warzone_protocol::sender_keys::SenderKeyDistribution {
|
||||
sender_fingerprint: sender_fingerprint.clone(),
|
||||
group_name: group_name.clone(),
|
||||
chain_key,
|
||||
generation,
|
||||
};
|
||||
let sender_key = dist.into_sender_key();
|
||||
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!(
|
||||
"Received sender key from {} for #{}",
|
||||
&sender_fingerprint[..sender_fingerprint.len().min(12)],
|
||||
group_name
|
||||
),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
WireMessage::CallSignal {
|
||||
id: _,
|
||||
sender_fingerprint,
|
||||
signal_type,
|
||||
payload: _,
|
||||
target: _,
|
||||
} => {
|
||||
use warzone_protocol::message::CallSignalType;
|
||||
let sender_short = { cache_eth_lookup(&sender_fingerprint, client, eth_cache); display_sender(&sender_fingerprint, eth_cache) };
|
||||
match signal_type {
|
||||
CallSignalType::Offer => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("\u{1f4de} Incoming call from {} \u{2014} /accept or /reject", sender_short),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
// Terminal bell for incoming call
|
||||
print!("\x07");
|
||||
}
|
||||
CallSignalType::Answer => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("\u{2713} {} accepted the call", sender_short),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
CallSignalType::Hangup => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Call ended".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
CallSignalType::Reject => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("{} rejected the call", sender_short),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
CallSignalType::Ringing => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Ringing...".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
CallSignalType::Busy => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("{} is busy", sender_short),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: sender_short,
|
||||
text: format!("\u{1f4de} Call signal: {:?}", signal_type),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Real-time message loop via WebSocket (falls back to HTTP polling).
|
||||
pub async fn poll_loop(
|
||||
messages: Arc<Mutex<Vec<ChatLine>>>,
|
||||
receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||
pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||
our_fp: String,
|
||||
identity: IdentityKeyPair,
|
||||
db: Arc<LocalDb>,
|
||||
client: ServerClient,
|
||||
last_dm_peer: Arc<Mutex<Option<String>>>,
|
||||
connected: Arc<AtomicBool>,
|
||||
) {
|
||||
let fp = normfp(&our_fp);
|
||||
let eth_cache: EthCache = Arc::new(std::sync::Mutex::new(HashMap::new()));
|
||||
|
||||
// Try WebSocket first
|
||||
let ws_url = client.base_url
|
||||
.replace("http://", "ws://")
|
||||
.replace("https://", "wss://");
|
||||
let ws_url = format!("{}/v1/ws/{}", ws_url, fp);
|
||||
|
||||
loop {
|
||||
match tokio_tungstenite::connect_async(&ws_url).await {
|
||||
Ok((ws_stream, _)) => {
|
||||
connected.store(true, Ordering::Relaxed);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Real-time connection established".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
|
||||
use futures_util::StreamExt;
|
||||
let (_, mut read) = ws_stream.split();
|
||||
|
||||
while let Some(Ok(msg)) = read.next().await {
|
||||
match msg {
|
||||
tokio_tungstenite::tungstenite::Message::Binary(data) => {
|
||||
process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, ð_cache, &last_dm_peer);
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if json.get("type").and_then(|v| v.as_str()) == Some("missed_call") {
|
||||
let data = json.get("data").cloned().unwrap_or_default();
|
||||
let caller = data.get("caller_fp").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let ts = data.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let when = chrono::DateTime::from_timestamp(ts, 0)
|
||||
.map(|dt| dt.with_timezone(&Local).format("%H:%M").to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("\u{1f4de} Missed call from {} at {}", &caller[..caller.len().min(12)], when),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
print!("\x07");
|
||||
} else if json.get("type").and_then(|v| v.as_str()) == Some("bot_message") {
|
||||
let from = json.get("from_name").or(json.get("from")).and_then(|v| v.as_str()).unwrap_or("bot");
|
||||
let text_content = json.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: format!("@{}", from),
|
||||
text: text_content.to_string(),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
print!("\x07");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
connected.store(false, Ordering::Relaxed);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Connection lost, reconnecting...".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
connected.store(false, Ordering::Relaxed);
|
||||
// Fallback to HTTP polling
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let raw_msgs = match client.poll_messages(&our_fp).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for raw in &raw_msgs {
|
||||
process_incoming(raw, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, ð_cache, &last_dm_peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
256
warzone/crates/warzone-client/src/tui/types.rs
Normal file
256
warzone/crates/warzone-client/src/tui/types.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
|
||||
/// Maximum file size: 10 MB.
|
||||
pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
|
||||
/// Chunk size: 64 KB.
|
||||
pub const CHUNK_SIZE: usize = 64 * 1024;
|
||||
|
||||
/// State for tracking an incoming chunked file transfer.
|
||||
#[derive(Clone)]
|
||||
pub struct PendingFileTransfer {
|
||||
pub filename: String,
|
||||
pub total_chunks: u32,
|
||||
pub received: u32,
|
||||
pub chunks: Vec<Option<Vec<u8>>>,
|
||||
pub sha256: String,
|
||||
pub file_size: u64,
|
||||
}
|
||||
|
||||
/// Receipt status for a sent message.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ReceiptStatus {
|
||||
Sent,
|
||||
Delivered,
|
||||
Read,
|
||||
}
|
||||
|
||||
/// Active call information.
|
||||
#[derive(Clone)]
|
||||
pub struct CallInfo {
|
||||
pub peer_fp: String,
|
||||
pub peer_display: String,
|
||||
pub state: CallPhase,
|
||||
pub started_at: DateTime<Local>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum CallPhase {
|
||||
Calling, // we initiated, waiting for answer
|
||||
Ringing, // incoming call, waiting for user to accept/reject
|
||||
Active, // call connected
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub input: String,
|
||||
pub messages: Arc<Mutex<Vec<ChatLine>>>,
|
||||
pub our_fp: String,
|
||||
pub peer_fp: Option<String>,
|
||||
pub server_url: String,
|
||||
pub should_quit: bool,
|
||||
pub cursor_pos: usize,
|
||||
pub last_dm_peer: Arc<Mutex<Option<String>>>,
|
||||
/// Track receipt status for messages we sent, keyed by message ID.
|
||||
pub receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
|
||||
/// Pending incoming file transfers, keyed by file ID.
|
||||
pub pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
|
||||
/// Our ETH address (derived from seed).
|
||||
pub our_eth: String,
|
||||
/// Current peer's ETH address (resolved on /peer set).
|
||||
pub peer_eth: Option<String>,
|
||||
/// Scroll offset from bottom (0 = pinned to newest).
|
||||
pub scroll_offset: usize,
|
||||
/// Whether the WebSocket connection is active.
|
||||
pub connected: Arc<AtomicBool>,
|
||||
/// Current call state: None=idle, Some(state)=active
|
||||
pub call_state: Option<CallInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChatLine {
|
||||
pub sender: String,
|
||||
pub text: String,
|
||||
pub is_system: bool,
|
||||
pub is_self: bool,
|
||||
/// Message ID (for sent messages, used to track receipts).
|
||||
pub message_id: Option<String>,
|
||||
/// When this message was created/received.
|
||||
pub timestamp: DateTime<Local>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(our_fp: String, peer_fp: Option<String>, server_url: String) -> Self {
|
||||
// Derive ETH address from seed first (used in welcome messages)
|
||||
let our_eth = crate::keystore::load_seed_raw()
|
||||
.map(|seed| {
|
||||
let eth = warzone_protocol::ethereum::derive_eth_identity(&seed);
|
||||
eth.address.to_checksum()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let identity_display = if our_eth.is_empty() { our_fp.clone() } else { our_eth.clone() };
|
||||
|
||||
let messages = Arc::new(Mutex::new(vec![ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("You are {}", identity_display),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
}]));
|
||||
|
||||
if let Some(ref peer) = peer_fp {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Chatting with {}", peer),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
} else {
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "No peer set. Use /peer <fp>, /peer @alias, or /g <group>".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
}
|
||||
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "/alias /peer /g /gleave /gkick /gmembers /file /info /quit".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
|
||||
App {
|
||||
input: String::new(),
|
||||
messages,
|
||||
our_fp,
|
||||
peer_fp,
|
||||
server_url,
|
||||
should_quit: false,
|
||||
last_dm_peer: Arc::new(Mutex::new(None)),
|
||||
cursor_pos: 0,
|
||||
receipts: Arc::new(Mutex::new(HashMap::new())),
|
||||
pending_files: Arc::new(Mutex::new(HashMap::new())),
|
||||
our_eth,
|
||||
peer_eth: None,
|
||||
scroll_offset: 0,
|
||||
connected: Arc::new(AtomicBool::new(false)),
|
||||
call_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_message(&self, line: ChatLine) {
|
||||
self.messages.lock().unwrap().push(line);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normfp(fp: &str) -> String {
|
||||
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[test]
|
||||
fn app_new_initializes_scroll_offset_to_zero() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
assert_eq!(app.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_initializes_connected_to_false() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
assert!(!app.connected.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_creates_system_messages() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
let msgs = app.messages.lock().unwrap();
|
||||
assert!(msgs.len() >= 2);
|
||||
assert!(msgs[0].is_system);
|
||||
// First message shows ETH address (if seed exists) or fingerprint
|
||||
assert!(msgs[0].text.contains("You are"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_with_peer_shows_chatting_message() {
|
||||
let app = App::new("aabbcc".into(), Some("ddeeff".into()), "http://localhost:7700".into());
|
||||
let msgs = app.messages.lock().unwrap();
|
||||
let has_chatting = msgs.iter().any(|m| m.text.contains("Chatting with") && m.text.contains("ddeeff"));
|
||||
assert!(has_chatting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_without_peer_shows_no_peer_message() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
let msgs = app.messages.lock().unwrap();
|
||||
let has_no_peer = msgs.iter().any(|m| m.text.contains("No peer set"));
|
||||
assert!(has_no_peer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatline_has_timestamp() {
|
||||
let line = ChatLine {
|
||||
sender: "test".into(),
|
||||
text: "hello".into(),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
};
|
||||
// Timestamp should be within the last second
|
||||
let elapsed = Local::now().signed_duration_since(line.timestamp);
|
||||
assert!(elapsed.num_seconds() < 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_message_appends_to_list() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
let initial_count = app.messages.lock().unwrap().len();
|
||||
app.add_message(ChatLine {
|
||||
sender: "test".into(),
|
||||
text: "new message".into(),
|
||||
is_system: false,
|
||||
is_self: false,
|
||||
message_id: None,
|
||||
timestamp: Local::now(),
|
||||
});
|
||||
let new_count = app.messages.lock().unwrap().len();
|
||||
assert_eq!(new_count, initial_count + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normfp_strips_non_hex_and_lowercases() {
|
||||
assert_eq!(normfp("AA-BB-CC"), "aabbcc");
|
||||
assert_eq!(normfp("0x1234ABCD"), "01234abcd");
|
||||
assert_eq!(normfp("hello"), "e"); // only 'e' is hex
|
||||
assert_eq!(normfp("AABB"), "aabb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_cursor_pos_zero() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
assert_eq!(app.cursor_pos, 0);
|
||||
assert!(app.input.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_new_should_quit_false() {
|
||||
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
|
||||
assert!(!app.should_quit);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.21"
|
||||
version = "0.0.38"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"
|
||||
|
||||
113
warzone/crates/warzone-protocol/src/friends.rs
Normal file
113
warzone/crates/warzone-protocol/src/friends.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
//! Encrypted friend list — stored on server as opaque blob.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::crypto::{aead_encrypt, aead_decrypt, hkdf_derive};
|
||||
|
||||
/// A friend entry.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Friend {
|
||||
/// ETH address or fingerprint
|
||||
pub address: String,
|
||||
/// Optional display name / alias
|
||||
pub alias: Option<String>,
|
||||
/// When this friend was added (unix timestamp)
|
||||
pub added_at: i64,
|
||||
}
|
||||
|
||||
/// The full friend list (plaintext, before encryption).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct FriendList {
|
||||
pub friends: Vec<Friend>,
|
||||
}
|
||||
|
||||
impl FriendList {
|
||||
pub fn new() -> Self {
|
||||
FriendList { friends: vec![] }
|
||||
}
|
||||
|
||||
pub fn add(&mut self, address: &str, alias: Option<&str>) {
|
||||
// Don't add duplicates
|
||||
if self.friends.iter().any(|f| f.address == address) {
|
||||
return;
|
||||
}
|
||||
self.friends.push(Friend {
|
||||
address: address.to_string(),
|
||||
alias: alias.map(String::from),
|
||||
added_at: chrono::Utc::now().timestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, address: &str) {
|
||||
self.friends.retain(|f| f.address != address);
|
||||
}
|
||||
|
||||
/// Encrypt the friend list for server storage.
|
||||
/// Key is derived from the user's seed: HKDF(seed, info="warzone-friends").
|
||||
pub fn encrypt(&self, seed: &[u8; 32]) -> Vec<u8> {
|
||||
let key_bytes = hkdf_derive(seed, b"", b"warzone-friends", 32);
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
let plaintext = serde_json::to_vec(self).unwrap_or_default();
|
||||
aead_encrypt(&key, &plaintext, b"warzone-friends-aad")
|
||||
}
|
||||
|
||||
/// Decrypt a friend list blob from the server.
|
||||
pub fn decrypt(seed: &[u8; 32], ciphertext: &[u8]) -> Result<Self, crate::errors::ProtocolError> {
|
||||
let key_bytes = hkdf_derive(seed, b"", b"warzone-friends", 32);
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
let plaintext = aead_decrypt(&key, ciphertext, b"warzone-friends-aad")?;
|
||||
serde_json::from_slice(&plaintext)
|
||||
.map_err(|e| crate::errors::ProtocolError::RatchetError(format!("friend list json: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_roundtrip() {
|
||||
let seed = [42u8; 32];
|
||||
let mut list = FriendList::new();
|
||||
list.add("0x1234abcd", Some("alice"));
|
||||
list.add("0xdeadbeef", None);
|
||||
|
||||
let encrypted = list.encrypt(&seed);
|
||||
let decrypted = FriendList::decrypt(&seed, &encrypted).unwrap();
|
||||
|
||||
assert_eq!(decrypted.friends.len(), 2);
|
||||
assert_eq!(decrypted.friends[0].address, "0x1234abcd");
|
||||
assert_eq!(decrypted.friends[0].alias.as_deref(), Some("alice"));
|
||||
assert_eq!(decrypted.friends[1].address, "0xdeadbeef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_seed_fails() {
|
||||
let seed = [42u8; 32];
|
||||
let wrong_seed = [99u8; 32];
|
||||
let mut list = FriendList::new();
|
||||
list.add("0x1234", None);
|
||||
|
||||
let encrypted = list.encrypt(&seed);
|
||||
assert!(FriendList::decrypt(&wrong_seed, &encrypted).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_duplicate_add() {
|
||||
let mut list = FriendList::new();
|
||||
list.add("0x1234", None);
|
||||
list.add("0x1234", Some("alice"));
|
||||
assert_eq!(list.friends.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_works() {
|
||||
let mut list = FriendList::new();
|
||||
list.add("0x1234", None);
|
||||
list.add("0x5678", None);
|
||||
list.remove("0x1234");
|
||||
assert_eq!(list.friends.len(), 1);
|
||||
assert_eq!(list.friends[0].address, "0x5678");
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,4 @@ pub mod store;
|
||||
pub mod history;
|
||||
pub mod sender_keys;
|
||||
pub mod ethereum;
|
||||
pub mod friends;
|
||||
|
||||
@@ -43,7 +43,7 @@ pub enum ReceiptType {
|
||||
|
||||
/// Wire message format for transport between clients.
|
||||
/// Used by both CLI and WASM — MUST be identical for interop.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum WireMessage {
|
||||
/// First message to a peer: X3DH key exchange + first ratchet message.
|
||||
KeyExchange {
|
||||
@@ -132,3 +132,104 @@ pub enum CallSignalType {
|
||||
/// Peer is busy.
|
||||
Busy,
|
||||
}
|
||||
|
||||
/// Current wire protocol version.
|
||||
pub const WIRE_VERSION: u8 = 1;
|
||||
/// Magic bytes to identify versioned envelope: "WZ"
|
||||
pub const WIRE_MAGIC: [u8; 2] = [0x57, 0x5A];
|
||||
|
||||
/// Serialize a WireMessage with version envelope.
|
||||
/// Format: [0x57][0x5A][version: u8][length: u32 BE][bincode payload]
|
||||
pub fn serialize_envelope(msg: &WireMessage) -> Result<Vec<u8>, String> {
|
||||
let payload =
|
||||
bincode::serialize(msg).map_err(|e| format!("serialize: {}", e))?;
|
||||
let len = payload.len() as u32;
|
||||
let mut out = Vec::with_capacity(7 + payload.len());
|
||||
out.extend_from_slice(&WIRE_MAGIC);
|
||||
out.push(WIRE_VERSION);
|
||||
out.extend_from_slice(&len.to_be_bytes());
|
||||
out.extend_from_slice(&payload);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Deserialize a WireMessage, handling both envelope and legacy formats.
|
||||
/// - Envelope: [0x57][0x5A][version][length][payload]
|
||||
/// - Legacy: raw bincode (no envelope)
|
||||
pub fn deserialize_envelope(data: &[u8]) -> Result<WireMessage, String> {
|
||||
if data.len() >= 7 && data[0] == WIRE_MAGIC[0] && data[1] == WIRE_MAGIC[1] {
|
||||
let version = data[2];
|
||||
let len =
|
||||
u32::from_be_bytes([data[3], data[4], data[5], data[6]]) as usize;
|
||||
if version > WIRE_VERSION {
|
||||
return Err(format!(
|
||||
"unsupported wire version {} (max {}). Please update your client.",
|
||||
version, WIRE_VERSION
|
||||
));
|
||||
}
|
||||
if data.len() < 7 + len {
|
||||
return Err("truncated envelope".to_string());
|
||||
}
|
||||
bincode::deserialize(&data[7..7 + len])
|
||||
.map_err(|e| format!("v{} deserialize: {}", version, e))
|
||||
} else {
|
||||
// Legacy: raw bincode
|
||||
bincode::deserialize(data)
|
||||
.map_err(|e| format!("legacy deserialize: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod envelope_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn envelope_roundtrip() {
|
||||
let msg = WireMessage::Receipt {
|
||||
sender_fingerprint: "abc123".to_string(),
|
||||
message_id: "msg-001".to_string(),
|
||||
receipt_type: ReceiptType::Delivered,
|
||||
};
|
||||
let envelope = serialize_envelope(&msg).unwrap();
|
||||
assert_eq!(&envelope[..2], &WIRE_MAGIC);
|
||||
assert_eq!(envelope[2], WIRE_VERSION);
|
||||
|
||||
let decoded = deserialize_envelope(&envelope).unwrap();
|
||||
match decoded {
|
||||
WireMessage::Receipt { message_id, .. } => {
|
||||
assert_eq!(message_id, "msg-001")
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_still_works() {
|
||||
let msg = WireMessage::Receipt {
|
||||
sender_fingerprint: "abc123".to_string(),
|
||||
message_id: "msg-002".to_string(),
|
||||
receipt_type: ReceiptType::Read,
|
||||
};
|
||||
let raw = bincode::serialize(&msg).unwrap();
|
||||
let decoded = deserialize_envelope(&raw).unwrap();
|
||||
match decoded {
|
||||
WireMessage::Receipt { message_id, .. } => {
|
||||
assert_eq!(message_id, "msg-002")
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_version_rejected() {
|
||||
let mut envelope = serialize_envelope(&WireMessage::Receipt {
|
||||
sender_fingerprint: "x".into(),
|
||||
message_id: "y".into(),
|
||||
receipt_type: ReceiptType::Delivered,
|
||||
})
|
||||
.unwrap();
|
||||
envelope[2] = 99; // fake future version
|
||||
let result = deserialize_envelope(&envelope);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("unsupported wire version"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,15 +11,20 @@ use crate::errors::ProtocolError;
|
||||
|
||||
const MAX_SKIP: u32 = 1000;
|
||||
|
||||
/// Current serialization version for [`RatchetState`].
|
||||
const RATCHET_VERSION: u8 = 1;
|
||||
/// Magic byte to distinguish versioned from unversioned (legacy) data.
|
||||
const RATCHET_MAGIC: u8 = 0xFC;
|
||||
|
||||
/// A message produced by the ratchet.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RatchetMessage {
|
||||
pub header: RatchetHeader,
|
||||
pub ciphertext: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Header included with each ratchet message.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RatchetHeader {
|
||||
/// Current DH ratchet public key.
|
||||
pub dh_public: [u8; 32],
|
||||
@@ -208,6 +213,37 @@ impl RatchetState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize with version prefix: `[MAGIC][VERSION][bincode data]`.
|
||||
///
|
||||
/// Use [`deserialize_versioned`](Self::deserialize_versioned) to restore.
|
||||
pub fn serialize_versioned(&self) -> Result<Vec<u8>, String> {
|
||||
let data = bincode::serialize(self)
|
||||
.map_err(|e| format!("serialize: {}", e))?;
|
||||
let mut out = Vec::with_capacity(2 + data.len());
|
||||
out.push(RATCHET_MAGIC);
|
||||
out.push(RATCHET_VERSION);
|
||||
out.extend_from_slice(&data);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Deserialize with version awareness. Handles:
|
||||
/// - Versioned format: `[0xFC][version][bincode]`
|
||||
/// - Legacy format: raw bincode (no prefix)
|
||||
pub fn deserialize_versioned(data: &[u8]) -> Result<Self, String> {
|
||||
if data.len() >= 2 && data[0] == RATCHET_MAGIC {
|
||||
let version = data[1];
|
||||
match version {
|
||||
1 => bincode::deserialize(&data[2..])
|
||||
.map_err(|e| format!("v1 deserialize: {}", e)),
|
||||
_ => Err(format!("unknown ratchet version: {}", version)),
|
||||
}
|
||||
} else {
|
||||
// Legacy: try raw bincode (pre-versioning data)
|
||||
bincode::deserialize(data)
|
||||
.map_err(|e| format!("legacy deserialize: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
fn dh_ratchet_step(&mut self) -> Result<(), ProtocolError> {
|
||||
let their_pub = self
|
||||
.dh_remote
|
||||
@@ -312,6 +348,35 @@ mod tests {
|
||||
assert_eq!(bob.decrypt(&m2).unwrap(), b"two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_serialize_roundtrip() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
let msg = alice.encrypt(b"test versioning").unwrap();
|
||||
|
||||
// Save alice with versioned format
|
||||
let serialized = alice.serialize_versioned().unwrap();
|
||||
assert_eq!(serialized[0], 0xFC); // magic byte
|
||||
assert_eq!(serialized[1], 1); // version 1
|
||||
|
||||
// Restore and use
|
||||
let mut restored = RatchetState::deserialize_versioned(&serialized).unwrap();
|
||||
let msg2 = restored.encrypt(b"after restore").unwrap();
|
||||
let plain = bob.decrypt(&msg).unwrap();
|
||||
assert_eq!(plain, b"test versioning");
|
||||
let plain2 = bob.decrypt(&msg2).unwrap();
|
||||
assert_eq!(plain2, b"after restore");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_deserialize_works() {
|
||||
let (alice, _) = make_pair();
|
||||
// Serialize with raw bincode (legacy format)
|
||||
let legacy = bincode::serialize(&alice).unwrap();
|
||||
// Should still deserialize with versioned reader
|
||||
let restored = RatchetState::deserialize_versioned(&legacy).unwrap();
|
||||
assert_eq!(bincode::serialize(&restored).unwrap(), legacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_messages() {
|
||||
let (mut alice, mut bob) = make_pair();
|
||||
|
||||
@@ -163,4 +163,141 @@ mod tests {
|
||||
|
||||
assert_eq!(alice_result.shared_secret, bob_secret);
|
||||
}
|
||||
|
||||
/// Simulate the EXACT web client (WASM) flow:
|
||||
/// 1. Alice: generate identity + SPK, create bundle, register
|
||||
/// 2. Bob: same
|
||||
/// 3. Alice: fetch Bob's bundle, WasmSession::initiate (X3DH), encrypt_key_exchange
|
||||
/// 4. Bob: receive wire bytes, decrypt_wire_message (X3DH respond + ratchet decrypt)
|
||||
#[test]
|
||||
fn web_client_x3dh_roundtrip() {
|
||||
use crate::identity::Seed;
|
||||
use crate::message::WireMessage;
|
||||
use crate::ratchet::RatchetState;
|
||||
|
||||
// === Alice ===
|
||||
let alice_seed = Seed::generate();
|
||||
let alice_id = alice_seed.derive_identity();
|
||||
let alice_pub = alice_id.public_identity();
|
||||
let (alice_spk_secret, alice_spk) = generate_signed_pre_key(&alice_id, 1);
|
||||
let alice_bundle = PreKeyBundle {
|
||||
identity_key: *alice_pub.signing.as_bytes(),
|
||||
identity_encryption_key: *alice_pub.encryption.as_bytes(),
|
||||
signed_pre_key: alice_spk,
|
||||
one_time_pre_key: None, // web client: no OTPKs
|
||||
};
|
||||
|
||||
// === Bob ===
|
||||
let bob_seed = Seed::generate();
|
||||
let bob_id = bob_seed.derive_identity();
|
||||
let bob_pub = bob_id.public_identity();
|
||||
let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1);
|
||||
let bob_spk_secret_bytes = bob_spk_secret.to_bytes();
|
||||
let bob_bundle = PreKeyBundle {
|
||||
identity_key: *bob_pub.signing.as_bytes(),
|
||||
identity_encryption_key: *bob_pub.encryption.as_bytes(),
|
||||
signed_pre_key: bob_spk,
|
||||
one_time_pre_key: None,
|
||||
};
|
||||
let bob_bundle_bytes = bincode::serialize(&bob_bundle).unwrap();
|
||||
|
||||
// === Alice sends to Bob (simulating WasmSession::initiate + encrypt_key_exchange_with_id) ===
|
||||
|
||||
// Step 1: WasmSession::initiate — X3DH + init ratchet
|
||||
let x3dh_result = initiate(&alice_id, &bob_bundle).unwrap();
|
||||
let their_spk = PublicKey::from(bob_bundle.signed_pre_key.public_key);
|
||||
let mut alice_ratchet = RatchetState::init_alice(x3dh_result.shared_secret, their_spk);
|
||||
|
||||
// Step 2: encrypt_key_exchange_with_id — use SAME x3dh_result (NOT re-initiate!)
|
||||
let encrypted = alice_ratchet.encrypt(b"hello bob").unwrap();
|
||||
let wire = WireMessage::KeyExchange {
|
||||
id: "test-msg-001".to_string(),
|
||||
sender_fingerprint: alice_pub.fingerprint.to_string(),
|
||||
sender_identity_encryption_key: *alice_pub.encryption.as_bytes(),
|
||||
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
||||
used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id,
|
||||
ratchet_message: encrypted,
|
||||
};
|
||||
let wire_bytes = bincode::serialize(&wire).unwrap();
|
||||
|
||||
// === Bob decrypts (simulating decrypt_wire_message) ===
|
||||
let wire_in: WireMessage = bincode::deserialize(&wire_bytes).unwrap();
|
||||
match wire_in {
|
||||
WireMessage::KeyExchange {
|
||||
sender_identity_encryption_key,
|
||||
ephemeral_public,
|
||||
ratchet_message,
|
||||
..
|
||||
} => {
|
||||
let bob_spk_secret_restored = StaticSecret::from(bob_spk_secret_bytes);
|
||||
let their_id = PublicKey::from(sender_identity_encryption_key);
|
||||
let their_eph = PublicKey::from(ephemeral_public);
|
||||
|
||||
let shared = respond(
|
||||
&bob_id, &bob_spk_secret_restored, None, &their_id, &their_eph,
|
||||
).unwrap();
|
||||
|
||||
let bob_spk_for_ratchet = StaticSecret::from(bob_spk_secret_bytes);
|
||||
let mut bob_ratchet = RatchetState::init_bob(shared, bob_spk_for_ratchet);
|
||||
let plaintext = bob_ratchet.decrypt(&ratchet_message).unwrap();
|
||||
|
||||
assert_eq!(plaintext, b"hello bob");
|
||||
}
|
||||
_ => panic!("expected KeyExchange"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that the OLD buggy flow (double X3DH initiate) fails,
|
||||
/// confirming the bug we found.
|
||||
#[test]
|
||||
fn double_x3dh_initiate_fails() {
|
||||
use crate::identity::Seed;
|
||||
use crate::ratchet::RatchetState;
|
||||
|
||||
let alice_seed = Seed::generate();
|
||||
let alice_id = alice_seed.derive_identity();
|
||||
let alice_pub = alice_id.public_identity();
|
||||
|
||||
let bob_seed = Seed::generate();
|
||||
let bob_id = bob_seed.derive_identity();
|
||||
let bob_pub = bob_id.public_identity();
|
||||
let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1);
|
||||
let bob_spk_secret_bytes = bob_spk_secret.to_bytes();
|
||||
let bob_bundle = PreKeyBundle {
|
||||
identity_key: *bob_pub.signing.as_bytes(),
|
||||
identity_encryption_key: *bob_pub.encryption.as_bytes(),
|
||||
signed_pre_key: bob_spk,
|
||||
one_time_pre_key: None,
|
||||
};
|
||||
|
||||
// FIRST X3DH — used for ratchet
|
||||
let result1 = initiate(&alice_id, &bob_bundle).unwrap();
|
||||
let their_spk = PublicKey::from(bob_bundle.signed_pre_key.public_key);
|
||||
let mut alice_ratchet = RatchetState::init_alice(result1.shared_secret, their_spk);
|
||||
let encrypted = alice_ratchet.encrypt(b"test").unwrap();
|
||||
|
||||
// SECOND X3DH — different ephemeral key (THE BUG)
|
||||
let result2 = initiate(&alice_id, &bob_bundle).unwrap();
|
||||
// result2.ephemeral_public != result1.ephemeral_public
|
||||
assert_ne!(
|
||||
result1.ephemeral_public.as_bytes(),
|
||||
result2.ephemeral_public.as_bytes(),
|
||||
"two X3DH initiates should produce different ephemeral keys"
|
||||
);
|
||||
|
||||
// Bob tries to decrypt using result2's ephemeral (wrong one)
|
||||
let bob_spk_restored = StaticSecret::from(bob_spk_secret_bytes);
|
||||
let shared = respond(
|
||||
&bob_id, &bob_spk_restored, None,
|
||||
&alice_pub.encryption, &result2.ephemeral_public,
|
||||
).unwrap();
|
||||
|
||||
// The shared secrets DIFFER because different ephemeral keys
|
||||
assert_ne!(result1.shared_secret, shared, "mismatched ephemeral should produce different shared secret");
|
||||
|
||||
// Decryption should FAIL
|
||||
let bob_spk_for_ratchet = StaticSecret::from(bob_spk_secret_bytes);
|
||||
let mut bob_ratchet = RatchetState::init_bob(shared, bob_spk_for_ratchet);
|
||||
assert!(bob_ratchet.decrypt(&encrypted).is_err(), "decrypt should fail with wrong shared secret");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,3 +25,6 @@ rand.workspace = true
|
||||
futures-util = "0.3"
|
||||
ed25519-dalek.workspace = true
|
||||
bincode.workspace = true
|
||||
sha2.workspace = true
|
||||
reqwest = { workspace = true, features = ["rustls-tls", "json"] }
|
||||
tokio-tungstenite.workspace = true
|
||||
|
||||
84
warzone/crates/warzone-server/src/auth_middleware.rs
Normal file
84
warzone/crates/warzone-server/src/auth_middleware.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
//! Auth enforcement middleware: axum extractor that validates bearer tokens.
|
||||
//!
|
||||
//! Reads `Authorization: Bearer <token>` from request headers, validates via
|
||||
//! [`crate::routes::auth::validate_token`], and returns the authenticated
|
||||
//! fingerprint or a 401 rejection.
|
||||
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Extractor that validates a bearer token and provides the authenticated fingerprint.
|
||||
///
|
||||
/// Place this as the **first** parameter in any handler that requires authentication.
|
||||
/// The extractor will reject the request with 401 if the token is missing or invalid.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// async fn my_handler(
|
||||
/// auth: AuthFingerprint,
|
||||
/// State(state): State<AppState>,
|
||||
/// ) -> impl IntoResponse {
|
||||
/// let fp = auth.fingerprint; // guaranteed valid
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
pub struct AuthFingerprint {
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
#[axum::async_trait]
|
||||
impl FromRequestParts<AppState> for AuthFingerprint {
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let header = parts
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
let token = match header {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Err(AuthError::MissingToken),
|
||||
};
|
||||
|
||||
match crate::routes::auth::validate_token(&state.db.tokens, &token) {
|
||||
Some(fingerprint) => Ok(AuthFingerprint { fingerprint }),
|
||||
None => Err(AuthError::InvalidToken),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejection type for [`AuthFingerprint`] extractor failures.
|
||||
pub enum AuthError {
|
||||
/// No `Authorization: Bearer <token>` header was present (or it was empty).
|
||||
MissingToken,
|
||||
/// The token was present but did not pass validation (expired or unknown).
|
||||
InvalidToken,
|
||||
}
|
||||
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, msg) = match self {
|
||||
AuthError::MissingToken => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing or empty Authorization: Bearer <token> header",
|
||||
),
|
||||
AuthError::InvalidToken => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid or expired token",
|
||||
),
|
||||
};
|
||||
(status, axum::Json(serde_json::json!({ "error": msg }))).into_response()
|
||||
}
|
||||
}
|
||||
282
warzone/crates/warzone-server/src/botfather.rs
Normal file
282
warzone/crates/warzone-server/src/botfather.rs
Normal file
@@ -0,0 +1,282 @@
|
||||
//! Built-in BotFather: processes messages to @botfather and manages bot lifecycle.
|
||||
//!
|
||||
//! Supports: /start, /newbot, /mybots, /deletebot, /help
|
||||
//! Runs as a server-side handler — no external process needed.
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
const BOTFATHER_FP: &str = "00000000000000000b0ffa00e000000f";
|
||||
|
||||
/// Check if a message is destined for BotFather and handle it.
|
||||
/// Called from deliver_or_queue when the recipient is the BotFather fingerprint.
|
||||
/// Returns true if handled (message consumed).
|
||||
pub async fn handle_botfather_message(state: &AppState, from_fp: &str, message: &[u8]) -> bool {
|
||||
if !state.bots_enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to parse as plaintext bot_message JSON
|
||||
let bot_msg: serde_json::Value = match serde_json::from_slice(message) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false, // Encrypted messages can't be processed by built-in handler
|
||||
};
|
||||
|
||||
if bot_msg.get("type").and_then(|v| v.as_str()) != Some("bot_message") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let text = bot_msg
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let from_name = bot_msg
|
||||
.get("from_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(from_fp);
|
||||
|
||||
tracing::info!(
|
||||
"BotFather: message from {} ({}): {}",
|
||||
from_fp,
|
||||
from_name,
|
||||
text
|
||||
);
|
||||
|
||||
let response = match text {
|
||||
"/start" | "/help" => {
|
||||
"Welcome to BotFather! I can help you create and manage bots.\n\n\
|
||||
Commands:\n\
|
||||
/newbot - Create a new bot\n\
|
||||
/mybots - List your bots\n\
|
||||
/deletebot <name> - Delete a bot\n\
|
||||
/token <name> - Get bot token\n\
|
||||
/help - Show this message"
|
||||
.to_string()
|
||||
}
|
||||
t if t.starts_with("/newbot") => handle_newbot(state, from_fp, t).await,
|
||||
t if t.starts_with("/deletebot") => handle_deletebot(state, from_fp, t).await,
|
||||
"/mybots" => handle_mybots(state, from_fp).await,
|
||||
t if t.starts_with("/token") => handle_token(state, from_fp, t).await,
|
||||
_ => "I don't understand that command. Try /help".to_string(),
|
||||
};
|
||||
|
||||
// Send response back to the user
|
||||
send_botfather_reply(state, from_fp, &response).await;
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle_newbot(state: &AppState, owner_fp: &str, text: &str) -> String {
|
||||
// Parse: /newbot <name>
|
||||
let name = text.strip_prefix("/newbot").unwrap_or("").trim();
|
||||
if name.is_empty() {
|
||||
return "Usage: /newbot <botname>\n\nExample: /newbot WeatherBot\n\n\
|
||||
The name must end with 'bot' or 'Bot'."
|
||||
.to_string();
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if name.len() > 32 || name.len() < 3 {
|
||||
return "Bot name must be 3-32 characters.".to_string();
|
||||
}
|
||||
|
||||
let name_lower = name.to_lowercase();
|
||||
if !name_lower.ends_with("bot") {
|
||||
return "Bot name must end with 'bot' or 'Bot'. Example: WeatherBot, my_bot".to_string();
|
||||
}
|
||||
|
||||
// Check if alias is taken
|
||||
let alias_key = format!("a:{}", name_lower);
|
||||
if state
|
||||
.db
|
||||
.aliases
|
||||
.get(alias_key.as_bytes())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
return format!(
|
||||
"Sorry, @{} is already taken. Try a different name.",
|
||||
name_lower
|
||||
);
|
||||
}
|
||||
|
||||
// Generate fingerprint and token
|
||||
let fp_bytes: [u8; 16] = rand::random();
|
||||
let fp = hex::encode(fp_bytes);
|
||||
let token_rand: [u8; 16] = rand::random();
|
||||
let token = format!("{}:{}", &fp[..16], hex::encode(token_rand));
|
||||
|
||||
// Store bot info
|
||||
let bot_info = serde_json::json!({
|
||||
"name": name,
|
||||
"fingerprint": fp,
|
||||
"token": token,
|
||||
"owner": owner_fp,
|
||||
"e2e": false,
|
||||
"created_at": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
|
||||
let bot_key = format!("bot:{}", token);
|
||||
let _ = state.db.tokens.insert(
|
||||
bot_key.as_bytes(),
|
||||
serde_json::to_vec(&bot_info).unwrap_or_default(),
|
||||
);
|
||||
let fp_key = format!("bot_fp:{}", fp);
|
||||
let _ = state.db.tokens.insert(fp_key.as_bytes(), token.as_bytes());
|
||||
|
||||
// Register alias (all 3 keys needed for resolve_alias to work)
|
||||
let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes());
|
||||
let _ = state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), name_lower.as_bytes());
|
||||
let alias_record = serde_json::json!({
|
||||
"alias": name_lower,
|
||||
"fingerprint": fp,
|
||||
"recovery_key": "",
|
||||
"registered_at": chrono::Utc::now().timestamp(),
|
||||
"last_active": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let _ = state.db.aliases.insert(format!("rec:{}", name_lower).as_bytes(), serde_json::to_vec(&alias_record).unwrap_or_default());
|
||||
|
||||
tracing::info!(
|
||||
"BotFather: created bot @{} for owner {}",
|
||||
name_lower,
|
||||
owner_fp
|
||||
);
|
||||
|
||||
format!(
|
||||
"Done! Your new bot @{} is ready.\n\n\
|
||||
Token: {}\n\n\
|
||||
Keep this token secret! Use it to access the Bot API.\n\n\
|
||||
API endpoint: /v1/bot/{}/getUpdates",
|
||||
name_lower, token, token
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_deletebot(state: &AppState, owner_fp: &str, text: &str) -> String {
|
||||
let name = text
|
||||
.strip_prefix("/deletebot")
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if name.is_empty() {
|
||||
return "Usage: /deletebot <botname>".to_string();
|
||||
}
|
||||
|
||||
// Find the bot
|
||||
let alias_key = format!("a:{}", name);
|
||||
let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() {
|
||||
Some(v) => String::from_utf8_lossy(&v).to_string(),
|
||||
None => return format!("Bot @{} not found.", name),
|
||||
};
|
||||
|
||||
// Get bot info to verify ownership
|
||||
let token_key = format!("bot_fp:{}", bot_fp);
|
||||
let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() {
|
||||
Some(v) => String::from_utf8_lossy(&v).to_string(),
|
||||
None => return format!("Bot @{} not found in registry.", name),
|
||||
};
|
||||
|
||||
let bot_key = format!("bot:{}", token);
|
||||
if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() {
|
||||
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&info_bytes) {
|
||||
let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if owner != owner_fp && owner != "system" {
|
||||
return format!("You don't own @{}. Only the owner can delete it.", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete everything
|
||||
let _ = state.db.tokens.remove(bot_key.as_bytes());
|
||||
let _ = state.db.tokens.remove(token_key.as_bytes());
|
||||
let _ = state.db.aliases.remove(alias_key.as_bytes());
|
||||
let _ = state
|
||||
.db
|
||||
.aliases
|
||||
.remove(format!("fp:{}", bot_fp).as_bytes());
|
||||
let _ = state.db.keys.remove(bot_fp.as_bytes());
|
||||
|
||||
tracing::info!("BotFather: deleted bot @{} by owner {}", name, owner_fp);
|
||||
format!("Bot @{} has been deleted.", name)
|
||||
}
|
||||
|
||||
async fn handle_mybots(state: &AppState, owner_fp: &str) -> String {
|
||||
let mut bots = Vec::new();
|
||||
|
||||
for item in state.db.tokens.iter().flatten() {
|
||||
let key = String::from_utf8_lossy(&item.0).to_string();
|
||||
if !key.starts_with("bot:") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&item.1) {
|
||||
let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if owner == owner_fp {
|
||||
let name = info.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let e2e = info.get("e2e").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let mode = if e2e { "E2E" } else { "plaintext" };
|
||||
bots.push(format!(" @{} ({})", name.to_lowercase(), mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bots.is_empty() {
|
||||
"You have no bots. Use /newbot <name> to create one.".to_string()
|
||||
} else {
|
||||
format!("Your bots ({}):\n{}", bots.len(), bots.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_token(state: &AppState, owner_fp: &str, text: &str) -> String {
|
||||
let name = text
|
||||
.strip_prefix("/token")
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if name.is_empty() {
|
||||
return "Usage: /token <botname>".to_string();
|
||||
}
|
||||
|
||||
let alias_key = format!("a:{}", name);
|
||||
let bot_fp = match state.db.aliases.get(alias_key.as_bytes()).ok().flatten() {
|
||||
Some(v) => String::from_utf8_lossy(&v).to_string(),
|
||||
None => return format!("Bot @{} not found.", name),
|
||||
};
|
||||
|
||||
let token_key = format!("bot_fp:{}", bot_fp);
|
||||
let token = match state.db.tokens.get(token_key.as_bytes()).ok().flatten() {
|
||||
Some(v) => String::from_utf8_lossy(&v).to_string(),
|
||||
None => return format!("Token not found for @{}.", name),
|
||||
};
|
||||
|
||||
// Verify ownership
|
||||
let bot_key = format!("bot:{}", token);
|
||||
if let Some(info_bytes) = state.db.tokens.get(bot_key.as_bytes()).ok().flatten() {
|
||||
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&info_bytes) {
|
||||
let owner = info.get("owner").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if owner != owner_fp {
|
||||
return format!("You don't own @{}.", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
format!("Token for @{}:\n{}", name, token)
|
||||
}
|
||||
|
||||
/// Send a reply from BotFather to a user.
|
||||
async fn send_botfather_reply(state: &AppState, to_fp: &str, text: &str) {
|
||||
let msg = serde_json::json!({
|
||||
"type": "bot_message",
|
||||
"id": uuid::Uuid::new_v4().to_string(),
|
||||
"from": BOTFATHER_FP,
|
||||
"from_name": "BotFather",
|
||||
"text": text,
|
||||
"timestamp": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let msg_bytes = serde_json::to_vec(&msg).unwrap_or_default();
|
||||
|
||||
// Deliver directly (don't go through deliver_or_queue to avoid recursion)
|
||||
if !state.push_to_client(to_fp, &msg_bytes).await {
|
||||
// Queue for offline pickup
|
||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||
let _ = state.db.messages.insert(key.as_bytes(), msg_bytes);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ pub struct Database {
|
||||
pub groups: sled::Tree,
|
||||
pub aliases: sled::Tree,
|
||||
pub tokens: sled::Tree,
|
||||
pub calls: sled::Tree,
|
||||
pub missed_calls: sled::Tree,
|
||||
pub friends: sled::Tree,
|
||||
pub eth_addresses: sled::Tree,
|
||||
_db: sled::Db,
|
||||
}
|
||||
|
||||
@@ -17,12 +21,20 @@ impl Database {
|
||||
let groups = db.open_tree("groups")?;
|
||||
let aliases = db.open_tree("aliases")?;
|
||||
let tokens = db.open_tree("tokens")?;
|
||||
let calls = db.open_tree("calls")?;
|
||||
let missed_calls = db.open_tree("missed_calls")?;
|
||||
let friends = db.open_tree("friends")?;
|
||||
let eth_addresses = db.open_tree("eth_addresses")?;
|
||||
Ok(Database {
|
||||
keys,
|
||||
messages,
|
||||
groups,
|
||||
aliases,
|
||||
tokens,
|
||||
calls,
|
||||
missed_calls,
|
||||
friends,
|
||||
eth_addresses,
|
||||
_db: db,
|
||||
})
|
||||
}
|
||||
|
||||
340
warzone/crates/warzone-server/src/federation.rs
Normal file
340
warzone/crates/warzone-server/src/federation.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
//! Federation: two-server message relay via persistent WebSocket.
|
||||
//!
|
||||
//! Each server maintains a WS connection to its peer. Presence updates
|
||||
//! and message forwards flow over this single connection. Reconnects
|
||||
//! automatically on failure.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Federation configuration loaded from JSON.
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct FederationConfig {
|
||||
pub server_id: String,
|
||||
pub shared_secret: String,
|
||||
pub peer: PeerConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct PeerConfig {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Load federation config from a JSON file.
|
||||
pub fn load_config(path: &str) -> anyhow::Result<FederationConfig> {
|
||||
let data = std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read federation config '{}': {}", path, e))?;
|
||||
let config: FederationConfig = serde_json::from_str(&data)
|
||||
.map_err(|e| anyhow::anyhow!("invalid federation config: {}", e))?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Remote presence: which fingerprints are on the peer server.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemotePresence {
|
||||
pub peer_id: String,
|
||||
pub fingerprints: HashSet<String>,
|
||||
pub last_updated: i64,
|
||||
pub connected: bool,
|
||||
}
|
||||
|
||||
impl RemotePresence {
|
||||
pub fn new(peer_id: String) -> Self {
|
||||
RemotePresence {
|
||||
peer_id,
|
||||
fingerprints: HashSet::new(),
|
||||
last_updated: 0,
|
||||
connected: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains(&self, fp: &str) -> bool {
|
||||
self.connected && self.fingerprints.contains(fp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sender for outgoing federation messages over the WS.
|
||||
pub type FederationSender = Arc<Mutex<Option<tokio::sync::mpsc::UnboundedSender<String>>>>;
|
||||
|
||||
/// Handle for communicating with the federation peer.
|
||||
#[derive(Clone)]
|
||||
pub struct FederationHandle {
|
||||
pub config: FederationConfig,
|
||||
pub remote_presence: Arc<Mutex<RemotePresence>>,
|
||||
/// Channel to send messages over the outgoing WS to the peer.
|
||||
pub outgoing: FederationSender,
|
||||
/// HTTP client for one-shot requests (key fetch, etc.)
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl FederationHandle {
|
||||
pub fn new(config: FederationConfig) -> Self {
|
||||
let remote_presence = Arc::new(Mutex::new(RemotePresence::new(
|
||||
config.peer.id.clone(),
|
||||
)));
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
FederationHandle {
|
||||
config,
|
||||
remote_presence,
|
||||
outgoing: Arc::new(Mutex::new(None)),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a fingerprint is known to be on the peer server.
|
||||
pub async fn is_remote(&self, fp: &str) -> bool {
|
||||
let rp = self.remote_presence.lock().await;
|
||||
rp.contains(fp)
|
||||
}
|
||||
|
||||
/// Forward a message to the peer server via the persistent WS.
|
||||
pub async fn forward_message(&self, to_fp: &str, message: &[u8]) -> bool {
|
||||
let msg = serde_json::json!({
|
||||
"type": "forward",
|
||||
"to": to_fp,
|
||||
"message": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, message),
|
||||
"from_server": self.config.server_id,
|
||||
});
|
||||
self.send_json(msg).await
|
||||
}
|
||||
|
||||
/// Fetch a pre-key bundle from the peer server (HTTP GET fallback).
|
||||
/// Used when a local key lookup fails and the fingerprint is on the remote.
|
||||
pub async fn fetch_remote_bundle(&self, fingerprint: &str) -> Option<Vec<u8>> {
|
||||
let url = format!("{}/v1/keys/{}", self.config.peer.url, fingerprint);
|
||||
let resp = self.client.get(&url).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await.ok()?;
|
||||
let bundle_b64 = data.get("bundle")?.as_str()?;
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, bundle_b64).ok()
|
||||
}
|
||||
|
||||
/// Resolve an alias on the peer server.
|
||||
/// Returns Some(fingerprint) if the peer knows this alias.
|
||||
pub async fn resolve_remote_alias(&self, alias: &str) -> Option<String> {
|
||||
let url = format!("{}/v1/alias/resolve/{}", self.config.peer.url, alias);
|
||||
let resp = self.client.get(&url).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await.ok()?;
|
||||
// Check for error (alias not found on peer)
|
||||
if data.get("error").is_some() {
|
||||
return None;
|
||||
}
|
||||
data.get("fingerprint").and_then(|v| v.as_str()).map(String::from)
|
||||
}
|
||||
|
||||
/// Check if an alias is already taken on the peer server.
|
||||
/// Returns true if the alias exists on the peer (taken).
|
||||
pub async fn is_alias_taken_remote(&self, alias: &str) -> bool {
|
||||
self.resolve_remote_alias(alias).await.is_some()
|
||||
}
|
||||
|
||||
/// Push local presence to peer via the persistent WS.
|
||||
pub async fn push_presence(&self, fingerprints: Vec<String>) -> bool {
|
||||
let msg = serde_json::json!({
|
||||
"type": "presence",
|
||||
"server_id": self.config.server_id,
|
||||
"fingerprints": fingerprints,
|
||||
});
|
||||
self.send_json(msg).await
|
||||
}
|
||||
|
||||
/// Send a JSON message over the outgoing WS channel.
|
||||
async fn send_json(&self, msg: serde_json::Value) -> bool {
|
||||
let guard = self.outgoing.lock().await;
|
||||
if let Some(ref tx) = *guard {
|
||||
let json_str = serde_json::to_string(&msg).unwrap_or_default();
|
||||
tx.send(json_str).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task: connect to peer's WS endpoint, send auth, then loop.
|
||||
/// Handles reconnection on failure.
|
||||
pub async fn outgoing_ws_loop(
|
||||
handle: FederationHandle,
|
||||
state: crate::state::AppState,
|
||||
) {
|
||||
let ws_url = handle.config.peer.url
|
||||
.replace("http://", "ws://")
|
||||
.replace("https://", "wss://");
|
||||
let ws_url = format!("{}/v1/federation/ws", ws_url);
|
||||
|
||||
loop {
|
||||
tracing::info!("Federation: connecting to peer {} at {}", handle.config.peer.id, ws_url);
|
||||
|
||||
match tokio_tungstenite::connect_async(&ws_url).await {
|
||||
Ok((ws_stream, _)) => {
|
||||
tracing::info!("Federation: connected to peer {}", handle.config.peer.id);
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
let (mut ws_tx, mut ws_rx) = ws_stream.split();
|
||||
|
||||
// Send auth as first message
|
||||
let auth_msg = serde_json::json!({
|
||||
"type": "auth",
|
||||
"secret": handle.config.shared_secret,
|
||||
"server_id": handle.config.server_id,
|
||||
});
|
||||
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(
|
||||
serde_json::to_string(&auth_msg).unwrap_or_default()
|
||||
)).await.is_err() {
|
||||
tracing::warn!("Federation: failed to send auth to peer");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set up outgoing channel
|
||||
let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
{
|
||||
let mut guard = handle.outgoing.lock().await;
|
||||
*guard = Some(out_tx);
|
||||
}
|
||||
{
|
||||
let mut rp = handle.remote_presence.lock().await;
|
||||
rp.connected = true;
|
||||
}
|
||||
|
||||
// Send initial presence
|
||||
let fps: Vec<String> = {
|
||||
let conns = state.connections.lock().await;
|
||||
conns.keys().cloned().collect()
|
||||
};
|
||||
let _ = handle.push_presence(fps).await;
|
||||
|
||||
// Spawn task to forward outgoing channel + periodic ping to WS
|
||||
let send_task = tokio::spawn(async move {
|
||||
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15));
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = out_rx.recv() => {
|
||||
match msg {
|
||||
Some(text) => {
|
||||
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
_ = ping_interval.tick() => {
|
||||
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Ping(vec![])).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn task to periodically re-push presence
|
||||
let presence_handle = handle.clone();
|
||||
let presence_conns = state.connections.clone();
|
||||
let presence_task = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let fps: Vec<String> = {
|
||||
let conns = presence_conns.lock().await;
|
||||
conns.keys().cloned().collect()
|
||||
};
|
||||
if !presence_handle.push_presence(fps).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Read incoming messages from peer
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
match msg {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
handle_incoming_federation_msg(&text, &handle, &state).await;
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Pong(_) => {} // keepalive response
|
||||
tokio_tungstenite::tungstenite::Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection lost
|
||||
send_task.abort();
|
||||
presence_task.abort();
|
||||
{
|
||||
let mut guard = handle.outgoing.lock().await;
|
||||
*guard = None;
|
||||
}
|
||||
{
|
||||
let mut rp = handle.remote_presence.lock().await;
|
||||
rp.connected = false;
|
||||
rp.fingerprints.clear();
|
||||
}
|
||||
tracing::warn!("Federation: lost connection to peer {}, reconnecting...", handle.config.peer.id);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Federation: failed to connect to peer {}: {}", handle.config.peer.id, e);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a single incoming JSON message from the federated peer WS.
|
||||
async fn handle_incoming_federation_msg(
|
||||
text: &str,
|
||||
handle: &FederationHandle,
|
||||
state: &crate::state::AppState,
|
||||
) {
|
||||
let parsed: serde_json::Value = match serde_json::from_str(text) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
match msg_type {
|
||||
"presence" => {
|
||||
let fingerprints: Vec<String> = parsed.get("fingerprints")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
let count = fingerprints.len();
|
||||
|
||||
let mut rp = handle.remote_presence.lock().await;
|
||||
rp.fingerprints = fingerprints.into_iter().collect();
|
||||
rp.last_updated = chrono::Utc::now().timestamp();
|
||||
tracing::debug!("Federation: received {} fingerprints from {}", count, server_id);
|
||||
}
|
||||
"forward" => {
|
||||
let to = parsed.get("to").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let message_b64 = parsed.get("message").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let from_server = parsed.get("from_server").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
|
||||
if let Ok(message) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, message_b64) {
|
||||
let delivered = state.push_to_client(to, &message).await;
|
||||
if !delivered {
|
||||
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
|
||||
let _ = state.db.messages.insert(key.as_bytes(), message.as_slice());
|
||||
tracing::info!("Federation: queued message from {} for offline {}", from_server, to);
|
||||
} else {
|
||||
tracing::debug!("Federation: delivered message from {} to {}", from_server, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("Federation: unknown message type '{}'", msg_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
pub mod auth_middleware;
|
||||
pub mod botfather;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod errors;
|
||||
pub mod federation;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use clap::Parser;
|
||||
|
||||
mod botfather;
|
||||
pub mod auth_middleware;
|
||||
mod config;
|
||||
mod db;
|
||||
mod errors;
|
||||
mod federation;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
@@ -16,6 +19,18 @@ struct Cli {
|
||||
/// Database directory
|
||||
#[arg(short, long, default_value = "./warzone-data")]
|
||||
data_dir: String,
|
||||
|
||||
/// Federation config file (JSON). Enables server-to-server message relay.
|
||||
#[arg(short, long)]
|
||||
federation: Option<String>,
|
||||
|
||||
/// Enable bot API (disabled by default)
|
||||
#[arg(long, default_value = "false")]
|
||||
enable_bots: bool,
|
||||
|
||||
/// System bots config file (JSON array). Bots are auto-created on startup.
|
||||
#[arg(long)]
|
||||
bots_config: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -30,11 +45,172 @@ async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
tracing::info!("Warzone server starting on {}", cli.bind);
|
||||
|
||||
let state = state::AppState::new(&cli.data_dir)?;
|
||||
let mut state = state::AppState::new(&cli.data_dir)?;
|
||||
|
||||
// Load federation config if provided
|
||||
if let Some(ref fed_path) = cli.federation {
|
||||
let fed_config = federation::load_config(fed_path)?;
|
||||
tracing::info!(
|
||||
"Federation enabled: server_id={}, peer={}@{}",
|
||||
fed_config.server_id, fed_config.peer.id, fed_config.peer.url
|
||||
);
|
||||
let handle = federation::FederationHandle::new(fed_config);
|
||||
state.federation = Some(handle);
|
||||
}
|
||||
|
||||
// Enable bot API if requested
|
||||
state.bots_enabled = cli.enable_bots;
|
||||
if cli.enable_bots {
|
||||
tracing::info!("Bot API enabled");
|
||||
|
||||
// Auto-create BotFather if it doesn't exist
|
||||
let botfather_fp = "00000000000000000b0ffa00e000000f";
|
||||
let botfather_key = format!("bot_fp:{}", botfather_fp);
|
||||
if state.db.tokens.get(botfather_key.as_bytes()).ok().flatten().is_none() {
|
||||
let token = format!("botfather:{}", hex::encode(rand::random::<[u8; 16]>()));
|
||||
let bot_info = serde_json::json!({
|
||||
"name": "BotFather",
|
||||
"fingerprint": botfather_fp,
|
||||
"token": token,
|
||||
"owner": "system",
|
||||
"e2e": false,
|
||||
"created_at": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let key = format!("bot:{}", token);
|
||||
let _ = state.db.tokens.insert(key.as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default());
|
||||
let _ = state.db.tokens.insert(botfather_key.as_bytes(), token.as_bytes());
|
||||
// Register alias
|
||||
let _ = state.db.aliases.insert(b"a:botfather", botfather_fp.as_bytes());
|
||||
let _ = state.db.aliases.insert(format!("fp:{}", botfather_fp).as_bytes(), b"botfather");
|
||||
tracing::info!("BotFather created: @botfather (token: {})", token);
|
||||
} else {
|
||||
tracing::info!("BotFather already exists");
|
||||
}
|
||||
// Always ensure alias exists (may have been lost on data wipe)
|
||||
let _ = state.db.aliases.insert(b"a:botfather", botfather_fp.as_bytes());
|
||||
let _ = state.db.aliases.insert(format!("fp:{}", botfather_fp).as_bytes(), b"botfather");
|
||||
// Store proper AliasRecord so resolve_alias works
|
||||
let bf_record = serde_json::json!({
|
||||
"alias": "botfather",
|
||||
"fingerprint": botfather_fp,
|
||||
"recovery_key": "",
|
||||
"registered_at": chrono::Utc::now().timestamp(),
|
||||
"last_active": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let _ = state.db.aliases.insert(b"rec:botfather", serde_json::to_vec(&bf_record).unwrap_or_default());
|
||||
|
||||
// Load system bots from config file
|
||||
if let Some(ref bots_path) = cli.bots_config {
|
||||
match std::fs::read_to_string(bots_path) {
|
||||
Ok(data) => {
|
||||
if let Ok(bots) = serde_json::from_str::<Vec<serde_json::Value>>(&data) {
|
||||
for bot in &bots {
|
||||
let name = bot.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let desc = bot.get("description").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if name.is_empty() { continue; }
|
||||
|
||||
let alias = name.to_lowercase();
|
||||
let alias_key = format!("a:{}", alias);
|
||||
|
||||
// Check if already exists
|
||||
let existing_fp = state.db.aliases.get(alias_key.as_bytes())
|
||||
.ok().flatten()
|
||||
.map(|v| String::from_utf8_lossy(&v).to_string());
|
||||
|
||||
let fp = if let Some(ref efp) = existing_fp {
|
||||
// Bot exists — just ensure alias record is intact
|
||||
efp.clone()
|
||||
} else {
|
||||
// Create new bot
|
||||
let fp_bytes: [u8; 16] = rand::random();
|
||||
let fp = hex::encode(fp_bytes);
|
||||
let token_rand: [u8; 16] = rand::random();
|
||||
let token = format!("{}:{}", &fp[..16], hex::encode(token_rand));
|
||||
|
||||
let bot_info = serde_json::json!({
|
||||
"name": name,
|
||||
"fingerprint": fp,
|
||||
"token": token,
|
||||
"owner": "system",
|
||||
"description": desc,
|
||||
"system_bot": true,
|
||||
"e2e": false,
|
||||
"created_at": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let _ = state.db.tokens.insert(format!("bot:{}", token).as_bytes(), serde_json::to_vec(&bot_info).unwrap_or_default());
|
||||
let _ = state.db.tokens.insert(format!("bot_fp:{}", fp).as_bytes(), token.as_bytes());
|
||||
let _ = state.db.aliases.insert(alias_key.as_bytes(), fp.as_bytes());
|
||||
let _ = state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), alias.as_bytes());
|
||||
tracing::info!("System bot @{} created (token: {})", alias, token);
|
||||
fp
|
||||
};
|
||||
|
||||
// Always ensure alias record exists
|
||||
let rec = serde_json::json!({
|
||||
"alias": alias,
|
||||
"fingerprint": fp,
|
||||
"recovery_key": "",
|
||||
"registered_at": chrono::Utc::now().timestamp(),
|
||||
"last_active": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let _ = state.db.aliases.insert(format!("rec:{}", alias).as_bytes(), serde_json::to_vec(&rec).unwrap_or_default());
|
||||
}
|
||||
tracing::info!("Loaded {} system bots from {}", bots.len(), bots_path);
|
||||
|
||||
// Write tokens to file for easy access
|
||||
let tokens_path = format!("{}/bot-tokens.txt", cli.data_dir);
|
||||
let mut token_lines = Vec::new();
|
||||
for bot in &bots {
|
||||
let name = bot.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if name.is_empty() { continue; }
|
||||
let alias = name.to_lowercase();
|
||||
if let Some(fp_bytes) = state.db.aliases.get(format!("a:{}", alias).as_bytes()).ok().flatten() {
|
||||
let fp = String::from_utf8_lossy(&fp_bytes).to_string();
|
||||
if let Some(tok_bytes) = state.db.tokens.get(format!("bot_fp:{}", fp).as_bytes()).ok().flatten() {
|
||||
let tok = String::from_utf8_lossy(&tok_bytes).to_string();
|
||||
token_lines.push(format!("{}={}", alias.to_uppercase(), tok));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !token_lines.is_empty() {
|
||||
let _ = std::fs::write(&tokens_path, token_lines.join("\n") + "\n");
|
||||
tracing::info!("Bot tokens written to {}", tokens_path);
|
||||
}
|
||||
|
||||
// Store bot list in DB for welcome screen
|
||||
let bot_list: Vec<serde_json::Value> = bots.iter().map(|b| {
|
||||
serde_json::json!({
|
||||
"name": b.get("name").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"description": b.get("description").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
})
|
||||
}).collect();
|
||||
let _ = state.db.tokens.insert(b"system:bot_list", serde_json::to_vec(&bot_list).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("Failed to load bots config '{}': {}", bots_path, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn federation outgoing WS connection if enabled
|
||||
if let Some(ref fed) = state.federation {
|
||||
let handle = fed.clone();
|
||||
let fed_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
federation::outgoing_ws_loop(handle, fed_state).await;
|
||||
});
|
||||
}
|
||||
|
||||
let cors = tower_http::cors::CorsLayer::new()
|
||||
.allow_origin(tower_http::cors::Any)
|
||||
.allow_methods(tower_http::cors::Any)
|
||||
.allow_headers(tower_http::cors::Any);
|
||||
|
||||
let app = axum::Router::new()
|
||||
.merge(routes::web_router())
|
||||
.nest("/v1", routes::router())
|
||||
.layer(cors)
|
||||
.layer(tower::limit::ConcurrencyLimitLayer::new(200))
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ struct RegisterRequest {
|
||||
/// - Expired aliases (past grace period) can be reclaimed by anyone
|
||||
/// - Expired aliases (within grace period) can only be reclaimed by recovery key
|
||||
async fn register_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -122,6 +123,18 @@ async fn register_alias(
|
||||
return Ok(Json(serde_json::json!({ "error": "alias must be 1-32 alphanumeric chars" })));
|
||||
}
|
||||
|
||||
// Reserve *Bot and *_bot suffixes for bots only
|
||||
let is_bot_name = alias.ends_with("bot") || alias.ends_with("_bot");
|
||||
if is_bot_name {
|
||||
// Check if this fingerprint is registered as a bot
|
||||
let bot_key = format!("bot_fp:{}", fp);
|
||||
let is_registered_bot = state.db.tokens.get(bot_key.as_bytes())
|
||||
.ok().flatten().is_some();
|
||||
if !is_registered_bot {
|
||||
return Ok(Json(serde_json::json!({ "error": "aliases ending with 'Bot' or '_bot' are reserved for bots — register via /v1/bot/register first" })));
|
||||
}
|
||||
}
|
||||
|
||||
// Check existing record for this alias
|
||||
if let Some(existing) = load_alias_record(&state.db.aliases, &alias) {
|
||||
if existing.fingerprint == fp {
|
||||
@@ -151,6 +164,13 @@ async fn register_alias(
|
||||
delete_alias_record(&state.db.aliases, &existing)?;
|
||||
}
|
||||
|
||||
// Check if alias is taken on federation peer (globally unique)
|
||||
if let Some(ref federation) = state.federation {
|
||||
if federation.is_alias_taken_remote(&alias).await {
|
||||
return Ok(Json(serde_json::json!({ "error": "alias already taken on federated server" })));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old alias for this fingerprint (one alias per person)
|
||||
if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
|
||||
let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string();
|
||||
@@ -190,6 +210,7 @@ struct RecoverRequest {
|
||||
|
||||
/// Recover an alias using the recovery key. Works even if expired (within or past grace).
|
||||
async fn recover_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RecoverRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -244,6 +265,7 @@ struct RenewRequest {
|
||||
|
||||
/// Renew/heartbeat — resets the TTL. Called automatically on activity.
|
||||
async fn renew_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RenewRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -289,7 +311,20 @@ async fn resolve_alias(
|
||||
})))
|
||||
}
|
||||
}
|
||||
None => Ok(Json(serde_json::json!({ "error": "alias not found" }))),
|
||||
None => {
|
||||
// Try federation peer
|
||||
if let Some(ref federation) = state.federation {
|
||||
if let Some(fp) = federation.resolve_remote_alias(&alias).await {
|
||||
tracing::info!("Alias @{} resolved via federation: {}", alias, fp);
|
||||
return Ok(Json(serde_json::json!({
|
||||
"alias": alias,
|
||||
"fingerprint": fp,
|
||||
"federated": true,
|
||||
})));
|
||||
}
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "error": "alias not found" })))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +382,7 @@ struct UnregisterRequest {
|
||||
|
||||
/// Remove your own alias.
|
||||
async fn unregister_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<UnregisterRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -381,6 +417,7 @@ struct AdminRemoveRequest {
|
||||
|
||||
/// Admin: remove any alias.
|
||||
async fn admin_remove_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<AdminRemoveRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
|
||||
1012
warzone/crates/warzone-server/src/routes/bot.rs
Normal file
1012
warzone/crates/warzone-server/src/routes/bot.rs
Normal file
File diff suppressed because it is too large
Load Diff
233
warzone/crates/warzone-server/src/routes/calls.rs
Normal file
233
warzone/crates/warzone-server/src/routes/calls.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::{AppState, CallState, CallStatus};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/calls/initiate", post(initiate_call))
|
||||
.route("/calls/:id", get(get_call))
|
||||
.route("/calls/:id/end", post(end_call))
|
||||
.route("/calls/active", get(active_calls))
|
||||
.route("/calls/missed", post(get_missed_calls))
|
||||
.route("/groups/:name/call", post(initiate_group_call))
|
||||
}
|
||||
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InitiateRequest {
|
||||
caller: String,
|
||||
callee: String,
|
||||
}
|
||||
|
||||
async fn initiate_call(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<InitiateRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let call_id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let call = CallState {
|
||||
call_id: call_id.clone(),
|
||||
caller_fp: normalize_fp(&req.caller),
|
||||
callee_fp: normalize_fp(&req.callee),
|
||||
group_name: None,
|
||||
room_id: None,
|
||||
status: CallStatus::Ringing,
|
||||
created_at: now,
|
||||
answered_at: None,
|
||||
ended_at: None,
|
||||
};
|
||||
state.active_calls.lock().await.insert(call_id.clone(), call.clone());
|
||||
state.db.calls.insert(call_id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
|
||||
tracing::info!("Call initiated: {} -> {}", call.caller_fp, call.callee_fp);
|
||||
Ok(Json(serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"status": "ringing",
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_call(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
// Try in-memory first, then DB
|
||||
if let Some(call) = state.active_calls.lock().await.get(&id) {
|
||||
return Ok(Json(serde_json::to_value(call)?));
|
||||
}
|
||||
if let Some(data) = state.db.calls.get(id.as_bytes())? {
|
||||
let call: CallState = serde_json::from_slice(&data)?;
|
||||
return Ok(Json(serde_json::to_value(&call)?));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "error": "call not found" })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EndCallRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
async fn end_call(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<EndCallRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let _fp = normalize_fp(&req.fingerprint);
|
||||
let mut calls = state.active_calls.lock().await;
|
||||
if let Some(mut call) = calls.remove(&id) {
|
||||
call.status = CallStatus::Ended;
|
||||
call.ended_at = Some(now);
|
||||
state.db.calls.insert(id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
|
||||
return Ok(Json(serde_json::json!({ "ok": true, "call_id": id })));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "error": "call not found or already ended" })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ActiveQuery {
|
||||
fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
async fn active_calls(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ActiveQuery>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let calls = state.active_calls.lock().await;
|
||||
let filtered: Vec<&CallState> = match q.fingerprint {
|
||||
Some(ref fp) => {
|
||||
let fp = normalize_fp(fp);
|
||||
calls.values().filter(|c| c.caller_fp == fp || c.callee_fp == fp).collect()
|
||||
}
|
||||
None => calls.values().collect(),
|
||||
};
|
||||
Ok(Json(serde_json::json!({ "calls": filtered })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MissedRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
async fn get_missed_calls(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<MissedRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
let prefix = format!("missed:{}", fp);
|
||||
let mut missed = Vec::new();
|
||||
let mut keys = Vec::new();
|
||||
for (key, value) in state.db.missed_calls.scan_prefix(prefix.as_bytes()).flatten() {
|
||||
if let Ok(record) = serde_json::from_slice::<serde_json::Value>(&value) {
|
||||
missed.push(record);
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
// Delete after reading
|
||||
for key in &keys {
|
||||
let _ = state.db.missed_calls.remove(key);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "missed_calls": missed })))
|
||||
}
|
||||
|
||||
// --- FC-5: Group call ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GroupCallRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
/// Deterministic room ID from group name: hex(SHA-256("featherchat-group:" + name)[:16])
|
||||
fn hash_room_name(group_name: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(format!("featherchat-group:{}", group_name).as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
hex::encode(&hash[..16])
|
||||
}
|
||||
|
||||
async fn initiate_group_call(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<GroupCallRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let caller_fp = normalize_fp(&req.fingerprint);
|
||||
|
||||
// Load group
|
||||
let group_data = match state.db.groups.get(name.as_bytes())? {
|
||||
Some(d) => d,
|
||||
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||
};
|
||||
let group: serde_json::Value = serde_json::from_slice(&group_data)?;
|
||||
let members: Vec<String> = group.get("members")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Verify caller is a member
|
||||
if !members.contains(&caller_fp) {
|
||||
return Ok(Json(serde_json::json!({ "error": "not a member of this group" })));
|
||||
}
|
||||
|
||||
let room_id = hash_room_name(&name);
|
||||
let call_id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// Create call state
|
||||
let call = CallState {
|
||||
call_id: call_id.clone(),
|
||||
caller_fp: caller_fp.clone(),
|
||||
callee_fp: "group".to_string(),
|
||||
group_name: Some(name.clone()),
|
||||
room_id: Some(room_id.clone()),
|
||||
status: CallStatus::Ringing,
|
||||
created_at: now,
|
||||
answered_at: None,
|
||||
ended_at: None,
|
||||
};
|
||||
state.active_calls.lock().await.insert(call_id.clone(), call.clone());
|
||||
state.db.calls.insert(call_id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
|
||||
|
||||
// Fan out CallSignal::Offer to all online members (except caller)
|
||||
let offer = warzone_protocol::message::WireMessage::CallSignal {
|
||||
id: call_id.clone(),
|
||||
sender_fingerprint: caller_fp.clone(),
|
||||
signal_type: warzone_protocol::message::CallSignalType::Offer,
|
||||
payload: serde_json::json!({ "room_id": room_id, "group": name }).to_string(),
|
||||
target: format!("#{}", name),
|
||||
};
|
||||
let encoded = bincode::serialize(&offer)?;
|
||||
|
||||
let mut delivered = 0;
|
||||
for member in &members {
|
||||
if *member == caller_fp { continue; }
|
||||
if state.push_to_client(member, &encoded).await {
|
||||
delivered += 1;
|
||||
} else {
|
||||
// Queue for offline members
|
||||
let key = format!("queue:{}:{}", member, uuid::Uuid::new_v4());
|
||||
state.db.messages.insert(key.as_bytes(), encoded.as_slice())?;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Group call #{}: room={}, caller={}, notified={}/{}",
|
||||
name, room_id, caller_fp, delivered, members.len() - 1);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"room_id": room_id,
|
||||
"group": name,
|
||||
"members_notified": delivered,
|
||||
"members_total": members.len() - 1,
|
||||
})))
|
||||
}
|
||||
102
warzone/crates/warzone-server/src/routes/devices.rs
Normal file
102
warzone/crates/warzone-server/src/routes/devices.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::auth_middleware::AuthFingerprint;
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/devices", get(list_devices))
|
||||
.route("/devices/:id/kick", post(kick_device))
|
||||
.route("/devices/revoke-all", post(revoke_all))
|
||||
}
|
||||
|
||||
/// List active WS connections for the authenticated user.
|
||||
async fn list_devices(
|
||||
auth: AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let devices = state.list_devices(&auth.fingerprint).await;
|
||||
let list: Vec<serde_json::Value> = devices
|
||||
.iter()
|
||||
.map(|(id, connected_at)| {
|
||||
serde_json::json!({
|
||||
"device_id": id,
|
||||
"connected_at": connected_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let count = list.len();
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": auth.fingerprint,
|
||||
"devices": list,
|
||||
"count": count,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Kick a specific device by ID. Requires auth -- only the device owner can kick.
|
||||
async fn kick_device(
|
||||
auth: AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(device_id): axum::extract::Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let kicked = state.kick_device(&auth.fingerprint, &device_id).await;
|
||||
if kicked {
|
||||
tracing::info!("Device {} kicked by {}", device_id, auth.fingerprint);
|
||||
Ok(Json(serde_json::json!({ "ok": true, "kicked": device_id })))
|
||||
} else {
|
||||
Ok(Json(serde_json::json!({ "error": "device not found" })))
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke all sessions except the current one. Panic button.
|
||||
async fn revoke_all(
|
||||
auth: AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let keep_device = req
|
||||
.get("keep_device_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let removed = state
|
||||
.revoke_all_except(&auth.fingerprint, keep_device)
|
||||
.await;
|
||||
|
||||
// Also clear all tokens for this fingerprint except the current one
|
||||
// Scan tokens tree for this fingerprint
|
||||
let mut tokens_to_remove = Vec::new();
|
||||
for item in state.db.tokens.iter().flatten() {
|
||||
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&item.1) {
|
||||
if val.get("fingerprint").and_then(|v| v.as_str()) == Some(&auth.fingerprint) {
|
||||
tokens_to_remove.push(item.0.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only remove tokens if we actually revoked devices
|
||||
let tokens_cleared = if removed > 0 {
|
||||
let count = tokens_to_remove.len();
|
||||
for key in &tokens_to_remove {
|
||||
let _ = state.db.tokens.remove(key);
|
||||
}
|
||||
count
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"Revoke-all for {}: {} devices removed, {} tokens cleared",
|
||||
auth.fingerprint,
|
||||
removed,
|
||||
tokens_cleared,
|
||||
);
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"devices_removed": removed,
|
||||
"tokens_cleared": tokens_cleared,
|
||||
})))
|
||||
}
|
||||
161
warzone/crates/warzone-server/src/routes/federation.rs
Normal file
161
warzone/crates/warzone-server/src/routes/federation.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
//! Federation route handlers: WS endpoint for peer servers + status.
|
||||
|
||||
use axum::{
|
||||
extract::{State, WebSocketUpgrade, ws::{Message, WebSocket}},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/federation/ws", get(federation_ws_handler))
|
||||
.route("/federation/status", get(federation_status))
|
||||
}
|
||||
|
||||
/// WebSocket endpoint for incoming peer server connections.
|
||||
async fn federation_ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_peer_ws(socket, state))
|
||||
}
|
||||
|
||||
/// Handle an incoming federation WS connection from the peer server.
|
||||
async fn handle_peer_ws(socket: WebSocket, state: AppState) {
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// First message must be auth
|
||||
let secret = match state.federation {
|
||||
Some(ref f) => f.config.shared_secret.clone(),
|
||||
None => {
|
||||
tracing::warn!("Federation: WS connection rejected -- federation not configured");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for auth message (5 second timeout)
|
||||
let auth_msg = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
ws_rx.next(),
|
||||
).await;
|
||||
|
||||
let peer_id = match auth_msg {
|
||||
Ok(Some(Ok(Message::Text(text)))) => {
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let msg_secret = parsed.get("secret").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
|
||||
if msg_type != "auth" || msg_secret != secret {
|
||||
tracing::warn!("Federation: WS auth failed from {}", server_id);
|
||||
return;
|
||||
}
|
||||
tracing::info!("Federation: peer {} authenticated via WS", server_id);
|
||||
server_id.to_string()
|
||||
} else {
|
||||
tracing::warn!("Federation: invalid auth JSON");
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Federation: no auth message received within timeout");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Process incoming messages from the authenticated peer
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&text) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
match msg_type {
|
||||
"presence" => {
|
||||
let fingerprints: Vec<String> = parsed.get("fingerprints")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
let count = fingerprints.len();
|
||||
|
||||
if let Some(ref federation) = state.federation {
|
||||
let mut rp = federation.remote_presence.lock().await;
|
||||
rp.fingerprints = fingerprints.into_iter().collect();
|
||||
rp.last_updated = chrono::Utc::now().timestamp();
|
||||
rp.connected = true;
|
||||
}
|
||||
tracing::debug!("Federation WS: {} announced {} fingerprints", peer_id, count);
|
||||
|
||||
// Send our presence back
|
||||
if let Some(ref federation) = state.federation {
|
||||
let fps: Vec<String> = {
|
||||
let conns = state.connections.lock().await;
|
||||
conns.keys().cloned().collect()
|
||||
};
|
||||
let reply = serde_json::json!({
|
||||
"type": "presence",
|
||||
"server_id": federation.config.server_id,
|
||||
"fingerprints": fps,
|
||||
});
|
||||
let _ = ws_tx.send(Message::Text(serde_json::to_string(&reply).unwrap_or_default())).await;
|
||||
}
|
||||
}
|
||||
"forward" => {
|
||||
let to = parsed.get("to").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let message_b64 = parsed.get("message").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let from_server = parsed.get("from_server").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
|
||||
if let Ok(message) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, message_b64) {
|
||||
let delivered = state.push_to_client(to, &message).await;
|
||||
if !delivered {
|
||||
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
|
||||
let _ = state.db.messages.insert(key.as_bytes(), message.as_slice());
|
||||
tracing::info!("Federation WS: queued from {} for offline {}", from_server, to);
|
||||
} else {
|
||||
tracing::debug!("Federation WS: delivered from {} to {}", from_server, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Peer disconnected
|
||||
if let Some(ref federation) = state.federation {
|
||||
let mut rp = federation.remote_presence.lock().await;
|
||||
rp.connected = false;
|
||||
rp.fingerprints.clear();
|
||||
}
|
||||
tracing::info!("Federation WS: peer {} disconnected", peer_id);
|
||||
}
|
||||
|
||||
/// Federation health status.
|
||||
async fn federation_status(
|
||||
State(state): State<AppState>,
|
||||
) -> Json<serde_json::Value> {
|
||||
match state.federation {
|
||||
Some(ref federation) => {
|
||||
let rp = federation.remote_presence.lock().await;
|
||||
Json(serde_json::json!({
|
||||
"enabled": true,
|
||||
"server_id": federation.config.server_id,
|
||||
"peer_id": federation.config.peer.id,
|
||||
"peer_url": federation.config.peer.url,
|
||||
"peer_connected": rp.connected,
|
||||
"remote_clients": rp.fingerprints.len(),
|
||||
"last_sync": rp.last_updated,
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
Json(serde_json::json!({ "enabled": false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
54
warzone/crates/warzone-server/src/routes/friends.rs
Normal file
54
warzone/crates/warzone-server/src/routes/friends.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth_middleware::AuthFingerprint;
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/friends", get(get_friends))
|
||||
.route("/friends", post(save_friends))
|
||||
}
|
||||
|
||||
/// Get the encrypted friend list blob for the authenticated user.
|
||||
async fn get_friends(
|
||||
auth: AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
match state.db.friends.get(auth.fingerprint.as_bytes())? {
|
||||
Some(data) => {
|
||||
let blob = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": auth.fingerprint,
|
||||
"data": blob,
|
||||
})))
|
||||
}
|
||||
None => Ok(Json(serde_json::json!({
|
||||
"fingerprint": auth.fingerprint,
|
||||
"data": null,
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SaveFriendsRequest {
|
||||
data: String, // base64-encoded encrypted blob
|
||||
}
|
||||
|
||||
/// Save the encrypted friend list blob.
|
||||
async fn save_friends(
|
||||
auth: AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SaveFriendsRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let blob = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &req.data)
|
||||
.map_err(|e| anyhow::anyhow!("invalid base64: {}", e))?;
|
||||
state.db.friends.insert(auth.fingerprint.as_bytes(), blob)?;
|
||||
tracing::info!("Saved friend list for {} ({} bytes)", auth.fingerprint, req.data.len());
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -169,6 +169,7 @@ async fn list_groups(
|
||||
/// queue infrastructure — group messages look like 1:1 messages to the
|
||||
/// recipient, but with a group tag.
|
||||
async fn send_to_group(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<GroupSendRequest>,
|
||||
@@ -210,6 +211,7 @@ async fn send_to_group(
|
||||
}
|
||||
|
||||
async fn leave_group(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<JoinRequest>,
|
||||
@@ -235,6 +237,7 @@ struct KickRequest {
|
||||
}
|
||||
|
||||
async fn kick_member(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<KickRequest>,
|
||||
@@ -276,16 +279,22 @@ async fn get_members(
|
||||
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||
};
|
||||
|
||||
// Resolve aliases for each member
|
||||
// Resolve aliases and online status for each member
|
||||
let mut members_info: Vec<serde_json::Value> = Vec::new();
|
||||
let mut online_count: usize = 0;
|
||||
for fp in &group.members {
|
||||
let alias = state.db.aliases.get(format!("fp:{}", fp).as_bytes())
|
||||
.ok().flatten()
|
||||
.map(|v| String::from_utf8_lossy(&v).to_string());
|
||||
let online = state.is_online(fp).await;
|
||||
if online {
|
||||
online_count += 1;
|
||||
}
|
||||
members_info.push(serde_json::json!({
|
||||
"fingerprint": fp,
|
||||
"alias": alias,
|
||||
"is_creator": *fp == group.creator,
|
||||
"online": online,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -293,5 +302,6 @@ async fn get_members(
|
||||
"name": group.name,
|
||||
"members": members_info,
|
||||
"count": members_info.len(),
|
||||
"online_count": online_count,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ struct RegisterRequest {
|
||||
#[serde(default)]
|
||||
device_id: Option<String>,
|
||||
bundle: Vec<u8>,
|
||||
#[serde(default)]
|
||||
eth_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -54,6 +56,7 @@ struct RegisterResponse {
|
||||
}
|
||||
|
||||
async fn register_keys(
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Json<RegisterResponse> {
|
||||
@@ -67,6 +70,16 @@ async fn register_keys(
|
||||
let device_key = format!("device:{}:{}", fp, device_id);
|
||||
let _ = state.db.keys.insert(device_key.as_bytes(), req.bundle);
|
||||
|
||||
// Store ETH address mapping if provided
|
||||
if let Some(ref eth) = req.eth_address {
|
||||
let eth_lower = eth.to_lowercase();
|
||||
// eth -> fp
|
||||
let _ = state.db.eth_addresses.insert(eth_lower.as_bytes(), fp.as_bytes());
|
||||
// fp -> eth (reverse lookup)
|
||||
let _ = state.db.eth_addresses.insert(format!("rev:{}", fp).as_bytes(), eth_lower.as_bytes());
|
||||
tracing::info!("ETH address mapped: {} -> {}", eth_lower, fp);
|
||||
}
|
||||
|
||||
tracing::info!("Registered bundle for {} (device: {})", fp, device_id);
|
||||
Json(RegisterResponse { ok: true })
|
||||
}
|
||||
@@ -84,9 +97,26 @@ async fn get_bundle(
|
||||
.collect();
|
||||
tracing::info!("get_bundle: DB contains {} keys: {:?}", all_keys.len(), all_keys);
|
||||
|
||||
// Check if this fingerprint registered locally (has a device: entry)
|
||||
let device_prefix = format!("device:{}:", key);
|
||||
let is_local = state.db.keys.scan_prefix(device_prefix.as_bytes()).next().is_some();
|
||||
|
||||
// For remote clients, always proxy from the federation peer (bundles may change)
|
||||
if !is_local {
|
||||
if let Some(ref federation) = state.federation {
|
||||
if let Some(bundle_bytes) = federation.fetch_remote_bundle(&key).await {
|
||||
tracing::info!("get_bundle: PROXIED from federation peer for {}", key);
|
||||
return Ok(Json(serde_json::json!({
|
||||
"fingerprint": fingerprint,
|
||||
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bundle_bytes),
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match state.db.keys.get(key.as_bytes()) {
|
||||
Ok(Some(data)) => {
|
||||
tracing::info!("get_bundle: FOUND {} bytes for {}", data.len(), key);
|
||||
tracing::info!("get_bundle: FOUND {} bytes for {} (local={})", data.len(), key, is_local);
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": fingerprint,
|
||||
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data),
|
||||
@@ -129,6 +159,7 @@ struct OtpkEntry {
|
||||
|
||||
/// Upload additional one-time pre-keys.
|
||||
async fn replenish_otpks(
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ReplenishRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
|
||||
@@ -9,9 +9,9 @@ use warzone_protocol::message::WireMessage;
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Try to extract the message ID from raw bincode-serialized WireMessage bytes.
|
||||
/// Try to extract the message ID from raw WireMessage bytes (envelope or legacy).
|
||||
fn extract_message_id(data: &[u8]) -> Option<String> {
|
||||
if let Ok(wire) = bincode::deserialize::<WireMessage>(data) {
|
||||
if let Ok(wire) = warzone_protocol::message::deserialize_envelope(data) {
|
||||
match wire {
|
||||
WireMessage::KeyExchange { id, .. } => Some(id),
|
||||
WireMessage::Message { id, .. } => Some(id),
|
||||
@@ -71,6 +71,7 @@ fn normalize_fp(fp: &str) -> String {
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SendRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -84,14 +85,11 @@ async fn send_message(
|
||||
}
|
||||
}
|
||||
|
||||
// Try WebSocket push first (instant delivery)
|
||||
if state.push_to_client(&to, &req.message).await {
|
||||
tracing::info!("Pushed message to {} via WS ({} bytes)", to, req.message.len());
|
||||
let delivered = state.deliver_or_queue(&to, &req.message).await;
|
||||
if delivered {
|
||||
tracing::info!("Delivered message to {} ({} bytes)", to, req.message.len());
|
||||
} else {
|
||||
// Queue in DB (offline delivery)
|
||||
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
|
||||
tracing::info!("Queuing message for {} ({} bytes)", to, req.message.len());
|
||||
state.db.messages.insert(key.as_bytes(), req.message)?;
|
||||
tracing::info!("Queued message for {} ({} bytes)", to, req.message.len());
|
||||
}
|
||||
|
||||
// Renew sender's alias TTL (sending = authenticated action)
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
mod aliases;
|
||||
pub mod auth;
|
||||
pub mod bot;
|
||||
mod calls;
|
||||
mod devices;
|
||||
mod federation;
|
||||
mod friends;
|
||||
mod groups;
|
||||
mod health;
|
||||
mod keys;
|
||||
pub mod messages;
|
||||
mod presence;
|
||||
mod resolve;
|
||||
mod web;
|
||||
mod ws;
|
||||
mod wzp;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
@@ -20,6 +28,14 @@ pub fn router() -> Router<AppState> {
|
||||
.merge(aliases::routes())
|
||||
.merge(auth::routes())
|
||||
.merge(ws::routes())
|
||||
.merge(calls::routes())
|
||||
.merge(devices::routes())
|
||||
.merge(presence::routes())
|
||||
.merge(wzp::routes())
|
||||
.merge(friends::routes())
|
||||
.merge(federation::routes())
|
||||
.merge(bot::routes())
|
||||
.merge(resolve::routes())
|
||||
}
|
||||
|
||||
/// Web UI router (served at root, outside /v1)
|
||||
|
||||
57
warzone/crates/warzone-server/src/routes/presence.rs
Normal file
57
warzone/crates/warzone-server/src/routes/presence.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/presence/:fingerprint", get(get_presence))
|
||||
.route("/presence/batch", post(batch_presence))
|
||||
}
|
||||
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||
}
|
||||
|
||||
async fn get_presence(
|
||||
State(state): State<AppState>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let fp = normalize_fp(&fingerprint);
|
||||
let online = state.is_online(&fp).await;
|
||||
let devices = state.device_count(&fp).await;
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": fp,
|
||||
"online": online,
|
||||
"devices": devices,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BatchRequest {
|
||||
fingerprints: Vec<String>,
|
||||
}
|
||||
|
||||
async fn batch_presence(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BatchRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let mut results = Vec::new();
|
||||
for fp in &req.fingerprints {
|
||||
let fp = normalize_fp(fp);
|
||||
let online = state.is_online(&fp).await;
|
||||
let devices = state.device_count(&fp).await;
|
||||
results.push(serde_json::json!({
|
||||
"fingerprint": fp,
|
||||
"online": online,
|
||||
"devices": devices,
|
||||
}));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "results": results })))
|
||||
}
|
||||
136
warzone/crates/warzone-server/src/routes/resolve.rs
Normal file
136
warzone/crates/warzone-server/src/routes/resolve.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Convert a fingerprint to a per-bot unique numeric ID.
|
||||
/// Hash(bot_token + user_fp) → i64. Different bots see different IDs for the same user.
|
||||
/// This prevents cross-bot user correlation (same privacy model as Telegram).
|
||||
pub fn fp_to_numeric_id_for_bot(fp: &str, bot_token: &str) -> i64 {
|
||||
use sha2::{Sha256, Digest};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bot_token.as_bytes());
|
||||
hasher.update(b":");
|
||||
hasher.update(fp.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
let mut arr = [0u8; 8];
|
||||
arr.copy_from_slice(&hash[..8]);
|
||||
(i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF) // ensure positive
|
||||
}
|
||||
|
||||
/// Convert a fingerprint hex string to a stable i64 ID (non-bot contexts).
|
||||
/// Uses first 8 bytes of the fingerprint as a positive i64.
|
||||
pub fn fp_to_numeric_id(fp: &str) -> i64 {
|
||||
let clean: String = fp.chars().filter(|c| c.is_ascii_hexdigit()).take(16).collect();
|
||||
let bytes = hex::decode(&clean).unwrap_or_default();
|
||||
if bytes.len() >= 8 {
|
||||
let mut arr = [0u8; 8];
|
||||
arr.copy_from_slice(&bytes[..8]);
|
||||
i64::from_be_bytes(arr) & 0x7FFFFFFFFFFFFFFF // ensure positive
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/resolve/:address", get(resolve_address))
|
||||
}
|
||||
|
||||
/// Resolve an address to a fingerprint.
|
||||
///
|
||||
/// Accepts: ETH address (`0x...`), alias (`@name`), or raw fingerprint.
|
||||
async fn resolve_address(
|
||||
State(state): State<AppState>,
|
||||
Path(address): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let addr = address.trim().to_lowercase();
|
||||
|
||||
// ETH address: 0x...
|
||||
if addr.starts_with("0x") {
|
||||
if let Some(fp_bytes) = state.db.eth_addresses.get(addr.as_bytes())? {
|
||||
let fp = String::from_utf8_lossy(&fp_bytes).to_string();
|
||||
return Ok(Json(serde_json::json!({
|
||||
"address": address,
|
||||
"fingerprint": fp,
|
||||
"numeric_id": fp_to_numeric_id(&fp),
|
||||
"type": "eth",
|
||||
})));
|
||||
}
|
||||
// Try federation
|
||||
if let Some(ref federation) = state.federation {
|
||||
let url = format!("{}/v1/resolve/{}", federation.config.peer.url, addr);
|
||||
if let Ok(resp) = federation.client.get(&url).send().await {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"address": address,
|
||||
"fingerprint": fp,
|
||||
"numeric_id": fp_to_numeric_id(fp),
|
||||
"type": "eth",
|
||||
"federated": true,
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(Json(serde_json::json!({ "error": "address not found" })));
|
||||
}
|
||||
|
||||
// Alias: @name
|
||||
if addr.starts_with('@') {
|
||||
let alias = &addr[1..];
|
||||
// Try local alias resolution
|
||||
let alias_key = format!("a:{}", alias);
|
||||
if let Some(fp_bytes) = state.db.aliases.get(alias_key.as_bytes())? {
|
||||
let fp = String::from_utf8_lossy(&fp_bytes).to_string();
|
||||
return Ok(Json(serde_json::json!({
|
||||
"address": address,
|
||||
"fingerprint": fp,
|
||||
"numeric_id": fp_to_numeric_id(&fp),
|
||||
"type": "alias",
|
||||
})));
|
||||
}
|
||||
// Try federation
|
||||
if let Some(ref federation) = state.federation {
|
||||
if let Some(fp) = federation.resolve_remote_alias(alias).await {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"address": address,
|
||||
"fingerprint": fp,
|
||||
"numeric_id": fp_to_numeric_id(&fp),
|
||||
"type": "alias",
|
||||
"federated": true,
|
||||
})));
|
||||
}
|
||||
}
|
||||
return Ok(Json(serde_json::json!({ "error": "alias not found" })));
|
||||
}
|
||||
|
||||
// Raw fingerprint: just echo back with optional reverse ETH lookup
|
||||
let fp = addr
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect::<String>();
|
||||
if fp.len() == 32 {
|
||||
let rev_key = format!("rev:{}", fp);
|
||||
let eth = state
|
||||
.db
|
||||
.eth_addresses
|
||||
.get(rev_key.as_bytes())?
|
||||
.map(|v| String::from_utf8_lossy(&v).to_string());
|
||||
return Ok(Json(serde_json::json!({
|
||||
"address": address,
|
||||
"fingerprint": fp,
|
||||
"numeric_id": fp_to_numeric_id(&fp),
|
||||
"eth_address": eth,
|
||||
"type": "fingerprint",
|
||||
})));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "error": "unrecognized address format" })))
|
||||
}
|
||||
@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
|
||||
|
||||
async fn service_worker() -> impl IntoResponse {
|
||||
([(header::CONTENT_TYPE, "application/javascript")], r##"
|
||||
const CACHE = 'wz-v1';
|
||||
const CACHE = 'wz-v20';
|
||||
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
@@ -71,18 +71,17 @@ self.addEventListener('fetch', e => {
|
||||
if (url.pathname.startsWith('/v1/')) return;
|
||||
// WS: skip
|
||||
if (url.protocol === 'ws:' || url.protocol === 'wss:') return;
|
||||
// Shell: cache first, network fallback
|
||||
// Network first, cache fallback (ensures updates are picked up immediately)
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(cached => cached || fetch(e.request).then(resp => {
|
||||
fetch(e.request).then(resp => {
|
||||
if (resp.ok && SHELL.includes(url.pathname)) {
|
||||
const clone = resp.clone();
|
||||
caches.open(CACHE).then(c => c.put(e.request, clone));
|
||||
}
|
||||
return resp;
|
||||
}).catch(() => {
|
||||
if (e.request.mode === 'navigate') {
|
||||
return caches.match('/');
|
||||
}
|
||||
}).catch(() => caches.match(e.request).then(cached => {
|
||||
if (cached) return cached;
|
||||
if (e.request.mode === 'navigate') return caches.match('/');
|
||||
}))
|
||||
);
|
||||
});
|
||||
@@ -155,11 +154,27 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
|
||||
#chat-header input { background: #1a1a2e; border: 1px solid #333; color: #e6a23c; padding: 2px 6px;
|
||||
border-radius: 3px; font-family: inherit; font-size: 0.85em; width: 280px; }
|
||||
|
||||
#messages { flex: 1; overflow-y: auto; padding: 8px 10px; -webkit-overflow-scrolling: touch; }
|
||||
#messages { flex: 1; overflow-y: scroll; padding: 8px 10px; -webkit-overflow-scrolling: touch; min-height: 0; max-height: calc(100dvh - 100px); }
|
||||
#messages::-webkit-scrollbar { width: 8px; }
|
||||
#messages::-webkit-scrollbar-track { background: #0a0a1a; }
|
||||
#messages::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
|
||||
#messages::-webkit-scrollbar-thumb:hover { background: #555; }
|
||||
.msg { padding: 2px 0; font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.msg code { background: #1a1a3e; padding: 1px 4px; border-radius: 3px; font-size: 0.95em; color: #4fc3f7; }
|
||||
.msg pre { background: #0d0d20; padding: 8px; border-radius: 4px; margin: 4px 0; overflow-x: auto; border: 1px solid #222; }
|
||||
.msg pre code { background: none; padding: 0; }
|
||||
.msg strong, .msg b { color: #fff; }
|
||||
.msg em, .msg i { color: #e6a23c; }
|
||||
.msg a { color: #4fc3f7; }
|
||||
.msg blockquote { border-left: 3px solid #444; padding-left: 8px; color: #888; margin: 4px 0; }
|
||||
.msg ul, .msg ol { padding-left: 20px; margin: 4px 0; }
|
||||
.msg h1, .msg h2, .msg h3 { color: #fff; margin: 6px 0 2px; }
|
||||
.msg h1 { font-size: 1.2em; } .msg h2 { font-size: 1.1em; } .msg h3 { font-size: 1em; }
|
||||
.msg .ts { color: #333; margin-right: 4px; }
|
||||
.msg .from-self { color: #4ade80; font-weight: bold; }
|
||||
.msg .from-sys { color: #5e9ca0; font-style: italic; }
|
||||
.identity-code { user-select: all; cursor: pointer; background: #1a1a3e; padding: 2px 6px; border-radius: 3px; color: #4ade80; font-family: monospace; }
|
||||
.identity-code:hover { background: #252550; }
|
||||
.msg .lock { color: #ff6b9d; }
|
||||
|
||||
#bottom { display: flex; padding: 6px; gap: 6px; border-top: 1px solid #222; background: #111;
|
||||
@@ -171,6 +186,30 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
|
||||
cursor: pointer; font-size: 14px; min-height: 40px; }
|
||||
#send-btn:hover { background: #c73e54; }
|
||||
|
||||
.addr { color: #4fc3f7; cursor: pointer; text-decoration: underline; }
|
||||
.addr:hover { color: #81d4fa; }
|
||||
|
||||
/* Call UI */
|
||||
#call-bar { display: none; padding: 6px 10px; background: #1a0a2e; border-bottom: 1px solid #4a1a5c;
|
||||
align-items: center; gap: 8px; font-size: 0.85em; }
|
||||
#call-bar.active { display: flex; }
|
||||
#call-bar .call-status { flex: 1; color: #ce93d8; }
|
||||
.call-btn { padding: 4px 12px; border: none; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 0.8em; }
|
||||
.call-btn-green { background: #2d5016; color: #4ade80; }
|
||||
.call-btn-green:hover { background: #3d6020; }
|
||||
.call-btn-red { background: #5c1a1a; color: #ff6b6b; }
|
||||
.call-btn-red:hover { background: #6c2a2a; }
|
||||
.call-btn-blue { background: #1a1a5c; color: #4fc3f7; }
|
||||
.call-btn-blue:hover { background: #2a2a6c; }
|
||||
.incoming-call { animation: pulse 1.5s infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
|
||||
.inline-kbd { margin: 4px 0; display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.inline-kbd-row { display: flex; gap: 4px; width: 100%; }
|
||||
.kbd-btn { padding: 4px 12px; background: #1a1a3e; border: 1px solid #333; border-radius: 4px;
|
||||
color: #4fc3f7; cursor: pointer; font-family: inherit; font-size: 0.8em; flex: 1; text-align: center; }
|
||||
.kbd-btn:hover { background: #252550; border-color: #4fc3f7; }
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.msg { font-size: 0.8em; }
|
||||
#chat-header input { width: 180px; }
|
||||
@@ -206,11 +245,19 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
|
||||
<!-- Chat screen -->
|
||||
<div id="chat" class="screen">
|
||||
<div id="chat-header">
|
||||
<span class="tag tag-fp" id="hdr-fp"></span>
|
||||
<span class="tag tag-fp" id="hdr-fp" style="cursor:pointer" title="Click to copy"></span>
|
||||
<span id="hdr-eth" style="display:none"></span>
|
||||
<span>→</span>
|
||||
<input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off">
|
||||
<span class="tag-server" id="hdr-server"></span>
|
||||
</div>
|
||||
<div id="call-bar">
|
||||
<span class="call-status" id="call-status">No active call</span>
|
||||
<button class="call-btn call-btn-blue" id="btn-call">📞 Call</button>
|
||||
<button class="call-btn call-btn-green" id="btn-accept" style="display:none">✓ Accept</button>
|
||||
<button class="call-btn call-btn-red" id="btn-reject" style="display:none">✗ Reject</button>
|
||||
<button class="call-btn call-btn-red" id="btn-hangup" style="display:none">End Call</button>
|
||||
</div>
|
||||
<div id="messages"></div>
|
||||
<div id="bottom">
|
||||
<label id="file-btn" title="Send file" style="padding:10px;background:#1a1a2e;border:1px solid #333;color:#c8d6e5;border-radius:50%;cursor:pointer;min-width:40px;min-height:40px;text-align:center;line-height:20px;font-size:1.1em">📎<input type="file" id="file-input" style="display:none"></label>
|
||||
@@ -220,16 +267,19 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import init, { WasmIdentity, WasmSession, decrypt_wire_message, self_test, debug_bundle_info, create_receipt } from '/wasm/warzone_wasm.js';
|
||||
import init, { WasmIdentity, WasmSession, decrypt_wire_message, self_test, debug_bundle_info, create_receipt, create_call_signal } from '/wasm/warzone_wasm.js';
|
||||
|
||||
const SERVER = window.location.origin;
|
||||
const $messages = document.getElementById('messages');
|
||||
const $input = document.getElementById('msg-input');
|
||||
const $peerInput = document.getElementById('peer-input');
|
||||
|
||||
// ── State ──
|
||||
// ═══════════════════════════════════════════════
|
||||
// SECTION: State & Config
|
||||
// ═══════════════════════════════════════════════
|
||||
let wasmIdentity = null; // WasmIdentity from WASM
|
||||
let myFingerprint = '';
|
||||
let myEthAddress = '';
|
||||
let mySeedHex = '';
|
||||
let sessions = {}; // peerFP -> { session: WasmSession, data: base64 }
|
||||
let peerBundles = {}; // peerFP -> bundle bytes
|
||||
@@ -237,7 +287,7 @@ let pollTimer = null;
|
||||
let ws = null; // WebSocket connection
|
||||
let wasmReady = false;
|
||||
|
||||
const VERSION = '0.0.17';
|
||||
const VERSION = '0.0.38';
|
||||
let DEBUG = true; // toggle with /debug command
|
||||
|
||||
// ── Receipt tracking ──
|
||||
@@ -298,7 +348,36 @@ function normFP(fp) {
|
||||
return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
// ── WASM-based crypto (same as CLI: X25519 + ChaCha20 + Double Ratchet) ──
|
||||
function makeAddressClickable(text) {
|
||||
// Match fingerprint format: xxxx:xxxx:xxxx:xxxx... (at least 4 groups)
|
||||
text = text.replace(/([0-9a-f]{4}(?::[0-9a-f]{4}){3,})/gi, function(match) {
|
||||
const fp = match.replace(/:/g, '');
|
||||
return '<span class="addr" data-addr="' + fp + '" title="Click to message">' + match + '</span>';
|
||||
});
|
||||
// Match ETH addresses: 0x followed by 40 hex chars
|
||||
text = text.replace(/(0x[0-9a-fA-F]{40})/g, function(match) {
|
||||
return '<span class="addr" data-addr="' + match + '" title="Click to message">' + match + '</span>';
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
function handleAddrClick(addr) {
|
||||
const input = document.getElementById('msg-input');
|
||||
if (input && input.value.trim().length > 0) {
|
||||
navigator.clipboard.writeText(addr).then(() => {
|
||||
addSys('Copied: ' + addr);
|
||||
});
|
||||
} else {
|
||||
$peerInput.value = addr;
|
||||
currentGroup = null;
|
||||
localStorage.setItem('wz-peer', addr);
|
||||
addSys('Peer set to ' + addr.slice(0,16) + '...');
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// SECTION: Crypto & Identity
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
async function initWasm() {
|
||||
await init('/wasm/warzone_wasm_bg.wasm');
|
||||
@@ -356,11 +435,12 @@ function loadSavedIdentity() {
|
||||
async function registerKey() {
|
||||
const fp = normFP(myFingerprint);
|
||||
const bundleBytes = wasmIdentity.bundle_bytes();
|
||||
dbg('Registering key, fp:', fp, 'bundle size:', bundleBytes.length);
|
||||
myEthAddress = wasmIdentity.eth_address();
|
||||
dbg('Registering key, fp:', fp, 'bundle size:', bundleBytes.length, 'eth:', myEthAddress);
|
||||
await fetch(SERVER + '/v1/keys/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fingerprint: fp, bundle: Array.from(bundleBytes) })
|
||||
body: JSON.stringify({ fingerprint: fp, bundle: Array.from(bundleBytes), eth_address: myEthAddress })
|
||||
});
|
||||
dbg('Key registered');
|
||||
}
|
||||
@@ -442,6 +522,47 @@ async function sendEncrypted(peerFP, plaintext) {
|
||||
return msgId;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// SECTION: Network & WebSocket
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
// URL deep links: /message/@alias, /message/0xABC, /group/#ops
|
||||
function handleDeepLink() {
|
||||
const path = window.location.pathname;
|
||||
if (path.startsWith('/message/')) {
|
||||
const target = decodeURIComponent(path.slice(9));
|
||||
if (target) {
|
||||
setTimeout(() => {
|
||||
$peerInput.value = target;
|
||||
if (target.startsWith('@')) {
|
||||
fetch(SERVER + '/v1/alias/resolve/' + target.slice(1)).then(r => r.json()).then(data => {
|
||||
if (!data.error) {
|
||||
$peerInput.value = data.fingerprint;
|
||||
currentGroup = null;
|
||||
localStorage.setItem('wz-peer', data.fingerprint);
|
||||
addSys('Deep link: peer set to ' + target + ' (' + data.fingerprint.slice(0,16) + '...)');
|
||||
} else {
|
||||
addSys('Deep link: unknown alias ' + target);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
currentGroup = null;
|
||||
localStorage.setItem('wz-peer', target);
|
||||
addSys('Deep link: peer set to ' + target.slice(0,16) + '...');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
} else if (path.startsWith('/group/')) {
|
||||
let group = decodeURIComponent(path.slice(7));
|
||||
if (group.startsWith('#')) group = group.slice(1);
|
||||
if (group) {
|
||||
setTimeout(() => {
|
||||
groupSwitch(group);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const fp = normFP(myFingerprint);
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
@@ -454,9 +575,95 @@ function connectWebSocket() {
|
||||
ws.onopen = () => {
|
||||
dbg('WebSocket connected');
|
||||
addSys('Real-time connection established');
|
||||
handleDeepLink();
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
// Text frame — could be a bot message or missed call notification
|
||||
try {
|
||||
const json = JSON.parse(event.data);
|
||||
if (json.type === 'missed_call') {
|
||||
addSys('Missed call from ' + (json.data?.caller_fp || 'unknown'));
|
||||
return;
|
||||
}
|
||||
if (json.type === 'bot_message') {
|
||||
const botName = json.from_name || json.from || 'bot';
|
||||
let msgText = json.text || '';
|
||||
const useHtml = json.parse_mode === 'HTML';
|
||||
addMsg('@' + botName, msgText, false, null, useHtml);
|
||||
lastDmPeer = json.from ? normFP(json.from) : '';
|
||||
// Render inline keyboard if present
|
||||
if (json.reply_markup && json.reply_markup.inline_keyboard) {
|
||||
const kbdDiv = document.createElement('div');
|
||||
kbdDiv.className = 'inline-kbd';
|
||||
for (const row of json.reply_markup.inline_keyboard) {
|
||||
const rowDiv = document.createElement('div');
|
||||
rowDiv.className = 'inline-kbd-row';
|
||||
for (const btn of row) {
|
||||
const btnEl = document.createElement('button');
|
||||
btnEl.className = 'kbd-btn';
|
||||
btnEl.textContent = btn.text;
|
||||
btnEl.onclick = async function() {
|
||||
const cbData = btn.callback_data || btn.text;
|
||||
const botFp = json.from ? normFP(json.from) : '';
|
||||
if (botFp) {
|
||||
const cbMsg = {type:'bot_message',id:Date.now().toString(),from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:cbData,timestamp:Math.floor(Date.now()/1000),is_callback:true};
|
||||
await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:botFp,from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(cbMsg)))})});
|
||||
addSys('Sent: ' + btn.text);
|
||||
}
|
||||
};
|
||||
rowDiv.appendChild(btnEl);
|
||||
}
|
||||
kbdDiv.appendChild(rowDiv);
|
||||
}
|
||||
$messages.appendChild(kbdDiv);
|
||||
$messages.scrollTop = $messages.scrollHeight;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (json.type === 'bot_edit') {
|
||||
addSys('[bot updated: ' + (json.text || '') + ']');
|
||||
// Render inline keyboard if present
|
||||
if (json.reply_markup && json.reply_markup.inline_keyboard) {
|
||||
const kbdDiv = document.createElement('div');
|
||||
kbdDiv.className = 'inline-kbd';
|
||||
for (const row of json.reply_markup.inline_keyboard) {
|
||||
const rowDiv = document.createElement('div');
|
||||
rowDiv.className = 'inline-kbd-row';
|
||||
for (const btn of row) {
|
||||
const btnEl = document.createElement('button');
|
||||
btnEl.className = 'kbd-btn';
|
||||
btnEl.textContent = btn.text;
|
||||
btnEl.onclick = async function() {
|
||||
const cbData = btn.callback_data || btn.text;
|
||||
const botFp = json.from ? normFP(json.from) : '';
|
||||
if (botFp) {
|
||||
const cbMsg = {type:'bot_message',id:Date.now().toString(),from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:cbData,timestamp:Math.floor(Date.now()/1000),is_callback:true};
|
||||
await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:botFp,from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(cbMsg)))})});
|
||||
addSys('Sent: ' + btn.text);
|
||||
}
|
||||
};
|
||||
rowDiv.appendChild(btnEl);
|
||||
}
|
||||
kbdDiv.appendChild(rowDiv);
|
||||
}
|
||||
$messages.appendChild(kbdDiv);
|
||||
$messages.scrollTop = $messages.scrollHeight;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (json.type === 'bot_document') {
|
||||
addMsg('@' + (json.from || 'bot'), '[Document: ' + json.document + ']', false);
|
||||
return;
|
||||
}
|
||||
} catch(e) {}
|
||||
// If not JSON or unrecognized, try treating as binary
|
||||
const bytes = new TextEncoder().encode(event.data);
|
||||
dbg('WS text frame treated as bytes,', bytes.length, 'bytes');
|
||||
await handleIncomingMessage(bytes);
|
||||
return;
|
||||
}
|
||||
const bytes = new Uint8Array(event.data);
|
||||
dbg('WS received', bytes.length, 'bytes');
|
||||
await handleIncomingMessage(bytes);
|
||||
@@ -486,6 +693,10 @@ async function handleIncomingMessage(bytes) {
|
||||
updateReceiptDisplay(result.message_id, result.receipt_type);
|
||||
return;
|
||||
}
|
||||
if (result.type === 'call_signal') {
|
||||
handleCallSignal(result);
|
||||
return;
|
||||
}
|
||||
if (result.type === 'file_header') {
|
||||
handleFileHeader(result);
|
||||
return;
|
||||
@@ -506,13 +717,16 @@ async function handleIncomingMessage(bytes) {
|
||||
|
||||
let fromLabel = result.sender.slice(0, 19);
|
||||
try {
|
||||
const ar = await fetch(SERVER + '/v1/alias/whois/' + senderFP);
|
||||
const ar = await fetch(SERVER + '/v1/resolve/' + senderFP);
|
||||
const ad = await ar.json();
|
||||
if (ad.alias) fromLabel = '@' + ad.alias;
|
||||
if (ad.eth_address) fromLabel = ad.eth_address.slice(0, 12) + '...';
|
||||
// Alias overrides ETH
|
||||
const aw = await fetch(SERVER + '/v1/alias/whois/' + senderFP);
|
||||
const adata = await aw.json();
|
||||
if (adata.alias) fromLabel = '@' + adata.alias;
|
||||
} catch(e) {}
|
||||
|
||||
addMsg(fromLabel, result.text, false);
|
||||
// Send delivery receipt
|
||||
if (result.message_id) sendReceipt(result.sender, result.message_id, 'delivered');
|
||||
lastDmPeer = normFP(result.sender);
|
||||
return;
|
||||
@@ -531,6 +745,7 @@ async function handleIncomingMessage(bytes) {
|
||||
updateReceiptDisplay(result.message_id, result.receipt_type);
|
||||
return;
|
||||
}
|
||||
if (result.type === 'call_signal') { handleCallSignal(result); return; }
|
||||
if (result.type === 'file_header') { handleFileHeader(result); return; }
|
||||
if (result.type === 'file_chunk') { handleFileChunk(result); return; }
|
||||
|
||||
@@ -543,13 +758,16 @@ async function handleIncomingMessage(bytes) {
|
||||
|
||||
let fromLabel = result.sender.slice(0, 19);
|
||||
try {
|
||||
const ar = await fetch(SERVER + '/v1/alias/whois/' + normFP(result.sender));
|
||||
const rfp = normFP(result.sender);
|
||||
const ar = await fetch(SERVER + '/v1/resolve/' + rfp);
|
||||
const ad = await ar.json();
|
||||
if (ad.alias) fromLabel = '@' + ad.alias;
|
||||
if (ad.eth_address) fromLabel = ad.eth_address.slice(0, 12) + '...';
|
||||
const aw = await fetch(SERVER + '/v1/alias/whois/' + rfp);
|
||||
const adata = await aw.json();
|
||||
if (adata.alias) fromLabel = '@' + adata.alias;
|
||||
} catch(e2) {}
|
||||
|
||||
addMsg(fromLabel, result.text, false);
|
||||
// Send delivery receipt
|
||||
if (result.message_id) sendReceipt(result.sender, result.message_id, 'delivered');
|
||||
lastDmPeer = normFP(result.sender);
|
||||
return;
|
||||
@@ -558,12 +776,84 @@ async function handleIncomingMessage(bytes) {
|
||||
}
|
||||
}
|
||||
|
||||
// Last try: raw JSON file messages (from web file upload)
|
||||
// Last try: raw JSON file messages (from web file upload) or bot messages
|
||||
try {
|
||||
const str = new TextDecoder().decode(bytes);
|
||||
const json = JSON.parse(str);
|
||||
if (json.type === 'file_header') { handleFileHeader(json); return; }
|
||||
if (json.type === 'file_chunk') { handleFileChunk(json); return; }
|
||||
// Handle bot messages (plaintext JSON from bot API)
|
||||
if (json.type === 'bot_message') {
|
||||
const botName = json.from_name || json.from || 'bot';
|
||||
let msgText = json.text || '';
|
||||
const useHtml = json.parse_mode === 'HTML';
|
||||
addMsg('@' + botName, msgText, false, null, useHtml);
|
||||
lastDmPeer = json.from ? normFP(json.from) : '';
|
||||
// Render inline keyboard if present
|
||||
if (json.reply_markup && json.reply_markup.inline_keyboard) {
|
||||
const kbdDiv = document.createElement('div');
|
||||
kbdDiv.className = 'inline-kbd';
|
||||
for (const row of json.reply_markup.inline_keyboard) {
|
||||
const rowDiv = document.createElement('div');
|
||||
rowDiv.className = 'inline-kbd-row';
|
||||
for (const btn of row) {
|
||||
const btnEl = document.createElement('button');
|
||||
btnEl.className = 'kbd-btn';
|
||||
btnEl.textContent = btn.text;
|
||||
btnEl.onclick = async function() {
|
||||
const cbData = btn.callback_data || btn.text;
|
||||
const botFp = json.from ? normFP(json.from) : '';
|
||||
if (botFp) {
|
||||
const cbMsg = {type:'bot_message',id:Date.now().toString(),from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:cbData,timestamp:Math.floor(Date.now()/1000),is_callback:true};
|
||||
await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:botFp,from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(cbMsg)))})});
|
||||
addSys('Sent: ' + btn.text);
|
||||
}
|
||||
};
|
||||
rowDiv.appendChild(btnEl);
|
||||
}
|
||||
kbdDiv.appendChild(rowDiv);
|
||||
}
|
||||
$messages.appendChild(kbdDiv);
|
||||
$messages.scrollTop = $messages.scrollHeight;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (json.type === 'bot_edit') {
|
||||
addSys('[bot updated message: ' + (json.text || '') + ']');
|
||||
// Render inline keyboard if present
|
||||
if (json.reply_markup && json.reply_markup.inline_keyboard) {
|
||||
const kbdDiv = document.createElement('div');
|
||||
kbdDiv.className = 'inline-kbd';
|
||||
for (const row of json.reply_markup.inline_keyboard) {
|
||||
const rowDiv = document.createElement('div');
|
||||
rowDiv.className = 'inline-kbd-row';
|
||||
for (const btn of row) {
|
||||
const btnEl = document.createElement('button');
|
||||
btnEl.className = 'kbd-btn';
|
||||
btnEl.textContent = btn.text;
|
||||
btnEl.onclick = async function() {
|
||||
const cbData = btn.callback_data || btn.text;
|
||||
const botFp = json.from ? normFP(json.from) : '';
|
||||
if (botFp) {
|
||||
const cbMsg = {type:'bot_message',id:Date.now().toString(),from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:cbData,timestamp:Math.floor(Date.now()/1000),is_callback:true};
|
||||
await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:botFp,from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(cbMsg)))})});
|
||||
addSys('Sent: ' + btn.text);
|
||||
}
|
||||
};
|
||||
rowDiv.appendChild(btnEl);
|
||||
}
|
||||
kbdDiv.appendChild(rowDiv);
|
||||
}
|
||||
$messages.appendChild(kbdDiv);
|
||||
$messages.scrollTop = $messages.scrollHeight;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (json.type === 'bot_document') {
|
||||
const caption = json.caption ? ' \u2014 ' + json.caption : '';
|
||||
addMsg('@' + (json.from || 'bot'), '[Document: ' + json.document + caption + ']', false);
|
||||
return;
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
dbg('ALL decrypt attempts failed');
|
||||
@@ -581,7 +871,9 @@ try {
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// ── UI ──
|
||||
// ═══════════════════════════════════════════════
|
||||
// SECTION: UI & Message Display
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
function ts() {
|
||||
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
@@ -593,6 +885,34 @@ function esc(s) {
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function renderMd(text) {
|
||||
let s = esc(text);
|
||||
// Code blocks: ```...```
|
||||
s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
|
||||
// Inline code: `...`
|
||||
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
// Bold: **...**
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// Italic: *...*
|
||||
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||||
// Headers: ### ... (at line start)
|
||||
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||
s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||
// Links: [text](url)
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||
// Blockquotes: > ...
|
||||
s = s.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
|
||||
// Unordered lists: - ...
|
||||
s = s.replace(/^- (.+)$/gm, '<li>$1</li>');
|
||||
s = s.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
|
||||
// Line breaks
|
||||
s = s.replace(/\n/g, '<br>');
|
||||
// Clean up br inside pre
|
||||
s = s.replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/g, (m, code) => '<pre><code>' + code.replace(/<br>/g, '\n') + '</code></pre>');
|
||||
return s;
|
||||
}
|
||||
|
||||
const PEER_COLORS = ['#e6a23c','#f56c9d','#67c7eb','#b39ddb','#ff8a65','#81c784','#ce93d8','#4fc3f7','#ffb74d','#aed581','#f06292','#4dd0e1'];
|
||||
|
||||
function peerColor(name) {
|
||||
@@ -659,7 +979,7 @@ function formatSize(n) {
|
||||
return (n/1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function addMsg(from, text, isSelf, messageId) {
|
||||
function addMsg(from, text, isSelf, messageId, rawHtml) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg';
|
||||
const color = isSelf ? '#4ade80' : peerColor(from);
|
||||
@@ -669,7 +989,12 @@ function addMsg(from, text, isSelf, messageId) {
|
||||
const status = (sentMsgReceipts[messageId] && sentMsgReceipts[messageId].status) || 'sent';
|
||||
receiptHtml = ' <span class="receipt" style="color:' + receiptColor(status) + '"> ' + receiptIndicator(status) + '</span>';
|
||||
}
|
||||
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + esc(from) + '</span>: ' + esc(text) + receiptHtml;
|
||||
const bodyHtml = rawHtml ? text : makeAddressClickable(renderMd(text));
|
||||
d.innerHTML = '<span class="ts">' + ts() + '</span> ' + lock + '<span style="color:' + color + ';font-weight:bold">' + makeAddressClickable(esc(from)) + '</span>: ' + bodyHtml + receiptHtml;
|
||||
// Attach click handler for .addr spans
|
||||
d.querySelectorAll('.addr').forEach(el => {
|
||||
el.addEventListener('click', () => handleAddrClick(el.dataset.addr));
|
||||
});
|
||||
$messages.appendChild(d);
|
||||
$messages.scrollTop = $messages.scrollHeight;
|
||||
// Store reference to the receipt span so we can update it later
|
||||
@@ -680,10 +1005,14 @@ function addMsg(from, text, isSelf, messageId) {
|
||||
}
|
||||
}
|
||||
|
||||
function addSys(text) {
|
||||
function addSys(text, rawHtml) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg';
|
||||
if (rawHtml) {
|
||||
d.innerHTML = '<span class="ts">' + ts() + '</span> <span class="from-sys">' + text + '</span>';
|
||||
} else {
|
||||
d.innerHTML = '<span class="ts">' + ts() + '</span> <span class="from-sys">' + esc(text) + '</span>';
|
||||
}
|
||||
$messages.appendChild(d);
|
||||
$messages.scrollTop = $messages.scrollHeight;
|
||||
}
|
||||
@@ -718,19 +1047,61 @@ let pendingFiles = {}; // file_id -> { filename, chunks: [], total, received,
|
||||
async function enterChat() {
|
||||
document.getElementById('setup').classList.remove('active');
|
||||
document.getElementById('chat').classList.add('active');
|
||||
document.getElementById('hdr-fp').textContent = myFingerprint.slice(0, 19);
|
||||
document.getElementById('hdr-server').textContent = SERVER;
|
||||
|
||||
await registerKey();
|
||||
addSys('Identity loaded: ' + myFingerprint);
|
||||
// Show ETH in header, fallback to fingerprint
|
||||
const hdrFp = document.getElementById('hdr-fp');
|
||||
if (myEthAddress) {
|
||||
hdrFp.textContent = myEthAddress.slice(0, 12) + '...';
|
||||
hdrFp.title = myEthAddress;
|
||||
hdrFp.onclick = function() { navigator.clipboard.writeText(myEthAddress); addSys('Copied: ' + myEthAddress); };
|
||||
} else {
|
||||
hdrFp.textContent = (myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19));
|
||||
hdrFp.title = myFingerprint;
|
||||
}
|
||||
addSys('Identity: <code class="identity-code" onclick="navigator.clipboard.writeText(this.textContent)">' + esc(myEthAddress || myFingerprint) + '</code>', true);
|
||||
addSys('Key registered with server');
|
||||
|
||||
addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above');
|
||||
addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /file · /info');
|
||||
addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info');
|
||||
|
||||
// Show system bots if available
|
||||
try {
|
||||
const botResp = await fetch(SERVER + '/v1/bot/list');
|
||||
const botData = await botResp.json();
|
||||
if (botData.ok && botData.bots && botData.bots.length > 0) {
|
||||
addSys('');
|
||||
addSys('Available bots:');
|
||||
for (const b of botData.bots) {
|
||||
addSys(' @' + b.name + ' — ' + b.description);
|
||||
}
|
||||
addSys('Message a bot: /peer @botname');
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
const savedPeer = localStorage.getItem('wz-peer');
|
||||
if (savedPeer) $peerInput.value = savedPeer;
|
||||
if (savedPeer) {
|
||||
$peerInput.value = savedPeer;
|
||||
}
|
||||
|
||||
connectWebSocket();
|
||||
|
||||
// Auto-join #ops if no peer/group set (create if needed)
|
||||
if (!savedPeer) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Create #ops if it doesn't exist (ignore error if already exists)
|
||||
await fetch(SERVER + '/v1/groups/create', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({name:'ops', creator: normFP(myFingerprint)}) });
|
||||
// Join (no auth needed for join in current setup)
|
||||
await fetch(SERVER + '/v1/groups/ops/join', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({fingerprint: normFP(myFingerprint)}) });
|
||||
currentGroup = 'ops';
|
||||
$peerInput.value = '#ops';
|
||||
addSys('Welcome! You have been added to #ops');
|
||||
} catch(e) { dbg('Auto-join #ops failed:', e); }
|
||||
}, 500);
|
||||
}
|
||||
|
||||
$input.focus();
|
||||
}
|
||||
|
||||
@@ -758,6 +1129,22 @@ async function groupJoin(name) {
|
||||
addSys('Joined group "' + name + '" (' + data.members + ' members)');
|
||||
}
|
||||
|
||||
async function showGroupMembers(groupName) {
|
||||
try {
|
||||
const resp = await fetch(SERVER + '/v1/groups/' + groupName + '/members');
|
||||
const data = await resp.json();
|
||||
if (data.members && data.members.length > 0) {
|
||||
const online = data.members.filter(m => m.online).length;
|
||||
addSys('Members of #' + groupName + ' (' + online + '/' + data.members.length + ' online):');
|
||||
for (const m of data.members) {
|
||||
const status = m.online ? '\u{1F7E2}' : '\u26AA';
|
||||
const label = m.alias ? '@' + m.alias : m.fingerprint.slice(0, 16) + '...';
|
||||
addSys(' ' + status + ' ' + label + (m.is_creator ? ' *' : ''));
|
||||
}
|
||||
}
|
||||
} catch(e) { dbg('Failed to fetch members:', e); }
|
||||
}
|
||||
|
||||
async function groupSwitch(name) {
|
||||
// Auto-join
|
||||
await groupJoin(name);
|
||||
@@ -767,6 +1154,7 @@ async function groupSwitch(name) {
|
||||
currentGroup = name;
|
||||
$peerInput.value = '#' + name;
|
||||
addSys('Switched to group "' + name + '" (' + data.count + ' members: ' + data.members.map(m => m.slice(0,8)).join(', ') + ')');
|
||||
await showGroupMembers(name);
|
||||
}
|
||||
|
||||
async function groupList() {
|
||||
@@ -821,10 +1209,193 @@ async function sendToGroup(groupName, text) {
|
||||
body: JSON.stringify({ from: myFP, messages })
|
||||
});
|
||||
|
||||
addMsg(myFingerprint.slice(0, 19) + ' [' + groupName + ']', text, true, null);
|
||||
addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)) + ' [' + groupName + ']', text, true, null);
|
||||
}
|
||||
|
||||
// ── Send handler ──
|
||||
// ═══════════════════════════════════════════════
|
||||
// SECTION: Call Signaling
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
let callState = 'idle'; // idle, calling, ringing, active
|
||||
let callPeer = null;
|
||||
|
||||
function updateCallUI() {
|
||||
const bar = document.getElementById('call-bar');
|
||||
const status = document.getElementById('call-status');
|
||||
const btnCall = document.getElementById('btn-call');
|
||||
const btnAccept = document.getElementById('btn-accept');
|
||||
const btnReject = document.getElementById('btn-reject');
|
||||
const btnHangup = document.getElementById('btn-hangup');
|
||||
|
||||
bar.classList.add('active');
|
||||
btnCall.style.display = 'none';
|
||||
btnAccept.style.display = 'none';
|
||||
btnReject.style.display = 'none';
|
||||
btnHangup.style.display = 'none';
|
||||
|
||||
switch(callState) {
|
||||
case 'idle':
|
||||
bar.classList.remove('active');
|
||||
// Show call button only if peer is set
|
||||
if ($peerInput.value.trim() && !$peerInput.value.startsWith('#')) {
|
||||
bar.classList.add('active');
|
||||
btnCall.style.display = '';
|
||||
status.textContent = 'Ready to call';
|
||||
}
|
||||
break;
|
||||
case 'calling':
|
||||
status.textContent = '\u{1F4DE} Calling ' + (callPeer || '...').slice(0, 16);
|
||||
status.className = 'call-status';
|
||||
btnHangup.style.display = '';
|
||||
break;
|
||||
case 'ringing':
|
||||
status.textContent = '\u{1F4DE} Incoming call from ' + (callPeer || '?').slice(0, 16);
|
||||
status.className = 'call-status incoming-call';
|
||||
btnAccept.style.display = '';
|
||||
btnReject.style.display = '';
|
||||
break;
|
||||
case 'active':
|
||||
status.textContent = '\u{1F50A} In call with ' + (callPeer || '?').slice(0, 16);
|
||||
status.className = 'call-status';
|
||||
btnHangup.style.display = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function startCall() {
|
||||
const peer = $peerInput.value.trim();
|
||||
if (!peer || peer.startsWith('#')) { addSys('Set a peer first'); return; }
|
||||
|
||||
callState = 'calling';
|
||||
callPeer = peer;
|
||||
updateCallUI();
|
||||
|
||||
// Send CallSignal::Offer via WS
|
||||
try {
|
||||
const signalBytes = create_call_signal(wasmIdentity, 'offer', '', normFP(peer));
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const fp = normFP(peer);
|
||||
const header = new TextEncoder().encode(fp.padEnd(64, '0').slice(0, 64));
|
||||
const payload = new Uint8Array(header.length + signalBytes.length);
|
||||
payload.set(header);
|
||||
payload.set(signalBytes, header.length);
|
||||
ws.send(payload);
|
||||
addSys('Calling ' + peer.slice(0, 16) + '...');
|
||||
}
|
||||
} catch(e) {
|
||||
addSys('Call failed: ' + e.message);
|
||||
callState = 'idle';
|
||||
updateCallUI();
|
||||
}
|
||||
}
|
||||
|
||||
function acceptCall() {
|
||||
if (callState !== 'ringing') return;
|
||||
callState = 'active';
|
||||
updateCallUI();
|
||||
|
||||
try {
|
||||
const signalBytes = create_call_signal(wasmIdentity, 'answer', '', normFP(callPeer));
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const fp = normFP(callPeer);
|
||||
const header = new TextEncoder().encode(fp.padEnd(64, '0').slice(0, 64));
|
||||
const payload = new Uint8Array(header.length + signalBytes.length);
|
||||
payload.set(header);
|
||||
payload.set(signalBytes, header.length);
|
||||
ws.send(payload);
|
||||
addSys('Call accepted');
|
||||
}
|
||||
} catch(e) { addSys('Accept failed: ' + e.message); }
|
||||
}
|
||||
|
||||
function rejectCall() {
|
||||
if (callState !== 'ringing') return;
|
||||
try {
|
||||
const signalBytes = create_call_signal(wasmIdentity, 'reject', '', normFP(callPeer));
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const fp = normFP(callPeer);
|
||||
const header = new TextEncoder().encode(fp.padEnd(64, '0').slice(0, 64));
|
||||
const payload = new Uint8Array(header.length + signalBytes.length);
|
||||
payload.set(header);
|
||||
payload.set(signalBytes, header.length);
|
||||
ws.send(payload);
|
||||
}
|
||||
} catch(e) {}
|
||||
addSys('Call rejected');
|
||||
callState = 'idle';
|
||||
callPeer = null;
|
||||
updateCallUI();
|
||||
}
|
||||
|
||||
function hangupCall() {
|
||||
if (callState === 'idle') return;
|
||||
try {
|
||||
const target = callPeer ? normFP(callPeer) : '';
|
||||
const signalBytes = create_call_signal(wasmIdentity, 'hangup', '', target);
|
||||
if (ws && ws.readyState === WebSocket.OPEN && target) {
|
||||
const header = new TextEncoder().encode(target.padEnd(64, '0').slice(0, 64));
|
||||
const payload = new Uint8Array(header.length + signalBytes.length);
|
||||
payload.set(header);
|
||||
payload.set(signalBytes, header.length);
|
||||
ws.send(payload);
|
||||
}
|
||||
} catch(e) {}
|
||||
addSys('Call ended');
|
||||
callState = 'idle';
|
||||
callPeer = null;
|
||||
updateCallUI();
|
||||
}
|
||||
|
||||
function handleCallSignal(signal) {
|
||||
const type = signal.signal_type;
|
||||
const sender = signal.sender;
|
||||
|
||||
switch(type) {
|
||||
case 'offer':
|
||||
if (callState === 'idle') {
|
||||
callState = 'ringing';
|
||||
callPeer = sender;
|
||||
updateCallUI();
|
||||
addSys('\u{1F4DE} Incoming call from ' + sender.slice(0, 16));
|
||||
// Play sound or vibrate
|
||||
try { navigator.vibrate && navigator.vibrate([200, 100, 200]); } catch(e) {}
|
||||
}
|
||||
break;
|
||||
case 'answer':
|
||||
if (callState === 'calling') {
|
||||
callState = 'active';
|
||||
updateCallUI();
|
||||
addSys('Call connected!');
|
||||
}
|
||||
break;
|
||||
case 'hangup':
|
||||
case 'reject':
|
||||
if (callState !== 'idle') {
|
||||
addSys('Call ended' + (type === 'reject' ? ' (rejected)' : ''));
|
||||
callState = 'idle';
|
||||
callPeer = null;
|
||||
updateCallUI();
|
||||
}
|
||||
break;
|
||||
case 'ringing':
|
||||
if (callState === 'calling') {
|
||||
addSys('Ringing...');
|
||||
}
|
||||
break;
|
||||
case 'busy':
|
||||
if (callState === 'calling') {
|
||||
addSys('Peer is busy');
|
||||
callState = 'idle';
|
||||
callPeer = null;
|
||||
updateCallUI();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// SECTION: Command Handlers
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
async function doSend() {
|
||||
const text = $input.value.trim();
|
||||
@@ -838,6 +1409,7 @@ async function doSend() {
|
||||
const aliasData = await aliasResp.json();
|
||||
const aliasStr = aliasData.alias ? ' (@' + aliasData.alias + ')' : '';
|
||||
addSys('Fingerprint: ' + myFingerprint + aliasStr);
|
||||
if (myEthAddress) addSys('ETH Address: ' + myEthAddress);
|
||||
return;
|
||||
}
|
||||
if (text === '/clear') { $messages.innerHTML = ''; return; }
|
||||
@@ -847,6 +1419,10 @@ async function doSend() {
|
||||
return;
|
||||
}
|
||||
if (text === '/debug') { DEBUG = !DEBUG; addSys('Debug logging: ' + (DEBUG ? 'ON (check browser console)' : 'OFF')); return; }
|
||||
if (text === '/call') { startCall(); return; }
|
||||
if (text === '/hangup' || text === '/end') { hangupCall(); return; }
|
||||
if (text === '/accept') { acceptCall(); return; }
|
||||
if (text === '/reject') { rejectCall(); return; }
|
||||
if (text === '/reset') {
|
||||
localStorage.clear();
|
||||
addSys('localStorage cleared. Refresh the page to start fresh.');
|
||||
@@ -871,6 +1447,11 @@ async function doSend() {
|
||||
} catch(e) { addSys('Bundle info error: ' + e); }
|
||||
return;
|
||||
}
|
||||
if (text === '/seed') {
|
||||
addSys('Your recovery seed (keep secret!):');
|
||||
addSys(wasmIdentity.mnemonic());
|
||||
return;
|
||||
}
|
||||
if (text === '/quit') { window.close(); return; }
|
||||
if (text === '/glist') { await groupList(); return; }
|
||||
if (text === '/dm') { currentGroup = null; addSys('Switched to DM mode'); $peerInput.value = localStorage.getItem('wz-peer') || ''; return; }
|
||||
@@ -921,24 +1502,26 @@ async function doSend() {
|
||||
$peerInput.value = lastDmPeer;
|
||||
try {
|
||||
await sendEncrypted(lastDmPeer, replyText.trim());
|
||||
addMsg(myFingerprint.slice(0, 19), replyText.trim(), true);
|
||||
addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)), replyText.trim(), true);
|
||||
} catch(e) { addSys('Reply failed: ' + e.message); }
|
||||
return;
|
||||
}
|
||||
if (text.startsWith('/p ') || text.startsWith('/peer ')) {
|
||||
let val = text.startsWith('/p ') ? text.slice(3).trim() : text.slice(6).trim();
|
||||
if (val.startsWith('@')) {
|
||||
const resp = await fetch(SERVER + '/v1/alias/resolve/' + val.slice(1));
|
||||
if (val.startsWith('@') || val.startsWith('0x') || val.startsWith('0X')) {
|
||||
const endpoint = val.startsWith('@') ? '/v1/alias/resolve/' + val.slice(1) : '/v1/resolve/' + val;
|
||||
const resp = await fetch(SERVER + endpoint);
|
||||
const data = await resp.json();
|
||||
if (data.error) { addSys('Unknown alias ' + val); return; }
|
||||
if (data.error) { addSys('Cannot resolve ' + val + ': ' + data.error); return; }
|
||||
$peerInput.value = data.fingerprint;
|
||||
addSys(val + ' → ' + data.fingerprint.slice(0,16) + '...');
|
||||
addSys(val + ' \u2192 ' + data.fingerprint.slice(0,16) + '...');
|
||||
} else {
|
||||
$peerInput.value = val;
|
||||
}
|
||||
currentGroup = null;
|
||||
localStorage.setItem('wz-peer', $peerInput.value);
|
||||
addSys('Peer set to ' + $peerInput.value.slice(0,16) + '...');
|
||||
updateCallUI();
|
||||
return;
|
||||
}
|
||||
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }
|
||||
@@ -970,6 +1553,32 @@ async function doSend() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (text === '/friend' || text === '/friends') {
|
||||
try {
|
||||
const resp = await fetch(SERVER + '/v1/friends', {
|
||||
headers: { 'Authorization': 'Bearer ' + normFP(myFingerprint) }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.data) {
|
||||
addSys('Friends:');
|
||||
addSys('(encrypted friend list stored on server -- use TUI for full friend management)');
|
||||
} else {
|
||||
addSys('No friends yet. Use /friend <address> to add.');
|
||||
}
|
||||
} catch(e) { addSys('Error: ' + e.message); }
|
||||
return;
|
||||
}
|
||||
if (text.startsWith('/friend ')) {
|
||||
const addr = text.slice(8).trim();
|
||||
if (!addr) { addSys('Usage: /friend <address>'); return; }
|
||||
addSys('Friend management requires TUI client (encrypted locally). Use warzone-client for full support.');
|
||||
addSys('Hint: /friend in TUI to manage friends with E2E encryption.');
|
||||
return;
|
||||
}
|
||||
if (text.startsWith('/unfriend ')) {
|
||||
addSys('Friend management requires TUI client (encrypted locally).');
|
||||
return;
|
||||
}
|
||||
if (text.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; }
|
||||
|
||||
// Send to group or DM
|
||||
@@ -986,21 +1595,45 @@ async function doSend() {
|
||||
let peer = $peerInput.value.trim();
|
||||
if (!peer || peer.startsWith('#')) { addSys('Set a peer fingerprint/@alias or use /g <group>'); return; }
|
||||
|
||||
if (peer.startsWith('@')) {
|
||||
const aliasName = peer.slice(1);
|
||||
const resp = await fetch(SERVER + '/v1/alias/resolve/' + aliasName);
|
||||
if (peer.startsWith('@') || peer.startsWith('0x') || peer.startsWith('0X')) {
|
||||
const endpoint = peer.startsWith('@') ? '/v1/alias/resolve/' + peer.slice(1) : '/v1/resolve/' + peer;
|
||||
const resp = await fetch(SERVER + endpoint);
|
||||
const data = await resp.json();
|
||||
if (data.error) { addSys('Unknown alias @' + aliasName); return; }
|
||||
if (data.error) { addSys('Cannot resolve ' + peer + ': ' + data.error); return; }
|
||||
peer = data.fingerprint;
|
||||
addSys('Resolved @' + aliasName + ' → ' + peer.slice(0,16) + '...');
|
||||
}
|
||||
|
||||
localStorage.setItem('wz-peer', $peerInput.value.trim());
|
||||
|
||||
// Check if peer is a bot — send plaintext instead of E2E
|
||||
let isBotPeer = false;
|
||||
const peerRaw = $peerInput.value.trim();
|
||||
// Check by alias name if peer was set via @alias
|
||||
if (peerRaw.startsWith('@')) {
|
||||
const aname = peerRaw.slice(1).toLowerCase();
|
||||
isBotPeer = aname.endsWith('bot') || aname.endsWith('_bot') || aname === 'botfather';
|
||||
}
|
||||
// Also check by fingerprint reverse-lookup
|
||||
if (!isBotPeer) {
|
||||
try {
|
||||
const wr = await fetch(SERVER + '/v1/alias/whois/' + peer);
|
||||
const wd = await wr.json();
|
||||
if (wd.alias && (wd.alias.endsWith('bot') || wd.alias.endsWith('Bot') || wd.alias.endsWith('_bot') || wd.alias === 'botfather')) isBotPeer = true;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
if (isBotPeer) {
|
||||
const msgId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString();
|
||||
const botMsg = {type:'bot_message',id:msgId,from:normFP(myFingerprint),from_name:myEthAddress||myFingerprint.slice(0,19),text:text,timestamp:Math.floor(Date.now()/1000)};
|
||||
await fetch(SERVER+'/v1/messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:normFP(peer),from:normFP(myFingerprint),message:Array.from(new TextEncoder().encode(JSON.stringify(botMsg)))})});
|
||||
addMsg((myEthAddress ? myEthAddress.slice(0,12)+'...' : myFingerprint.slice(0,19)), text, true, msgId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const msgId = await sendEncrypted(peer, text);
|
||||
sentMsgReceipts[msgId] = { status: 'sent', el: null };
|
||||
addMsg(myFingerprint.slice(0, 19), text, true, msgId);
|
||||
addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)), text, true, msgId);
|
||||
} catch(e) {
|
||||
addSys('Send failed: ' + e.message);
|
||||
}
|
||||
@@ -1021,6 +1654,14 @@ document.getElementById('btn-show-recover').onclick = () => document.getElementB
|
||||
document.getElementById('btn-recover').onclick = () => doRecover();
|
||||
document.getElementById('btn-enter').onclick = () => enterChat();
|
||||
document.getElementById('send-btn').onclick = () => doSend();
|
||||
document.getElementById('btn-call').onclick = () => startCall();
|
||||
document.getElementById('btn-accept').onclick = () => acceptCall();
|
||||
document.getElementById('btn-reject').onclick = () => rejectCall();
|
||||
document.getElementById('btn-hangup').onclick = () => hangupCall();
|
||||
document.getElementById('messages').onclick = () => { if (!window.getSelection().toString()) document.getElementById('msg-input').focus(); };
|
||||
document.getElementById('hdr-eth').onclick = function() {
|
||||
if (myEthAddress) navigator.clipboard.writeText(myEthAddress).then(() => addSys('Copied ETH address'));
|
||||
};
|
||||
document.getElementById('file-input').onchange = async function() {
|
||||
if (!this.files.length) return;
|
||||
const file = this.files[0];
|
||||
|
||||
@@ -21,9 +21,9 @@ use warzone_protocol::message::WireMessage;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Try to extract the message ID from raw bincode-serialized WireMessage bytes.
|
||||
/// Try to extract the message ID from raw WireMessage bytes (envelope or legacy).
|
||||
fn extract_message_id(data: &[u8]) -> Option<String> {
|
||||
if let Ok(wire) = bincode::deserialize::<WireMessage>(data) {
|
||||
if let Ok(wire) = warzone_protocol::message::deserialize_envelope(data) {
|
||||
match wire {
|
||||
WireMessage::KeyExchange { id, .. } => Some(id),
|
||||
WireMessage::Message { id, .. } => Some(id),
|
||||
@@ -66,18 +66,22 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// Register for push delivery
|
||||
let mut push_rx = state.register_ws(&fingerprint).await;
|
||||
let (_device_id, mut push_rx) = match state.register_ws(&fingerprint, None).await {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
tracing::warn!("WS {}: rejected — connection limit reached", fingerprint);
|
||||
return; // closes the socket
|
||||
}
|
||||
};
|
||||
|
||||
// Send any queued messages from DB
|
||||
let prefix = format!("queue:{}", fingerprint);
|
||||
let mut keys_to_delete = Vec::new();
|
||||
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
|
||||
if let Ok((key, value)) = item {
|
||||
if ws_tx.send(Message::Binary(value.to_vec().into())).await.is_ok() {
|
||||
for (key, value) in state.db.messages.scan_prefix(prefix.as_bytes()).flatten() {
|
||||
if ws_tx.send(Message::Binary(value.to_vec())).await.is_ok() {
|
||||
keys_to_delete.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in &keys_to_delete {
|
||||
let _ = state.db.messages.remove(key);
|
||||
}
|
||||
@@ -85,11 +89,34 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
tracing::info!("WS {}: flushed {} queued messages", fingerprint, keys_to_delete.len());
|
||||
}
|
||||
|
||||
// Flush missed calls (FC-7)
|
||||
let missed_prefix = format!("missed:{}", fingerprint);
|
||||
let mut missed_keys = Vec::new();
|
||||
for (key, value) in state.db.missed_calls.scan_prefix(missed_prefix.as_bytes()).flatten() {
|
||||
if let Ok(missed) = serde_json::from_slice::<serde_json::Value>(&value) {
|
||||
let wrapper = serde_json::json!({
|
||||
"type": "missed_call",
|
||||
"data": missed,
|
||||
});
|
||||
if let Ok(json_str) = serde_json::to_string(&wrapper) {
|
||||
if ws_tx.send(Message::Text(json_str)).await.is_ok() {
|
||||
missed_keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in &missed_keys {
|
||||
let _ = state.db.missed_calls.remove(key);
|
||||
}
|
||||
if !missed_keys.is_empty() {
|
||||
tracing::info!("WS {}: flushed {} missed call notifications", fingerprint, missed_keys.len());
|
||||
}
|
||||
|
||||
// Spawn task to forward push messages to WS
|
||||
let _fp_clone = fingerprint.clone();
|
||||
let mut push_task = tokio::spawn(async move {
|
||||
while let Some(msg) = push_rx.recv().await {
|
||||
if ws_tx.send(Message::Binary(msg.into())).await.is_err() {
|
||||
if ws_tx.send(Message::Binary(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -119,12 +146,76 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
}
|
||||
}
|
||||
|
||||
// Try push to connected client first
|
||||
if !state_clone.push_to_client(&to_fp, message).await {
|
||||
// Queue in DB
|
||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||
let _ = state_clone.db.messages.insert(key.as_bytes(), message);
|
||||
// Call signal side effects
|
||||
if let Ok(WireMessage::CallSignal { ref id, ref sender_fingerprint, ref signal_type, .. }) = warzone_protocol::message::deserialize_envelope(message) {
|
||||
use warzone_protocol::message::CallSignalType;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
match signal_type {
|
||||
CallSignalType::Offer => {
|
||||
let call = crate::state::CallState {
|
||||
call_id: id.clone(),
|
||||
caller_fp: sender_fingerprint.clone(),
|
||||
callee_fp: to_fp.clone(),
|
||||
group_name: None,
|
||||
room_id: None,
|
||||
status: crate::state::CallStatus::Ringing,
|
||||
created_at: now,
|
||||
answered_at: None,
|
||||
ended_at: None,
|
||||
};
|
||||
state_clone.active_calls.lock().await.insert(id.clone(), call.clone());
|
||||
// Persist to DB
|
||||
let _ = state_clone.db.calls.insert(
|
||||
id.as_bytes(),
|
||||
serde_json::to_vec(&call).unwrap_or_default(),
|
||||
);
|
||||
tracing::info!("Call {} started: {} -> {}", id, sender_fingerprint, to_fp);
|
||||
|
||||
// If callee is offline, record missed call (FC-7)
|
||||
if !state_clone.is_online(&to_fp).await {
|
||||
let missed_key = format!("missed:{}:{}", to_fp, id);
|
||||
let missed = serde_json::json!({
|
||||
"call_id": id,
|
||||
"caller_fp": sender_fingerprint,
|
||||
"timestamp": now,
|
||||
});
|
||||
let _ = state_clone.db.missed_calls.insert(
|
||||
missed_key.as_bytes(),
|
||||
serde_json::to_vec(&missed).unwrap_or_default(),
|
||||
);
|
||||
tracing::info!("Missed call recorded for offline user {}", to_fp);
|
||||
}
|
||||
}
|
||||
CallSignalType::Answer => {
|
||||
let mut calls = state_clone.active_calls.lock().await;
|
||||
if let Some(call) = calls.get_mut(id) {
|
||||
call.status = crate::state::CallStatus::Active;
|
||||
call.answered_at = Some(now);
|
||||
let _ = state_clone.db.calls.insert(
|
||||
id.as_bytes(),
|
||||
serde_json::to_vec(&call).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
tracing::info!("Call {} answered", id);
|
||||
}
|
||||
CallSignalType::Hangup | CallSignalType::Reject => {
|
||||
let mut calls = state_clone.active_calls.lock().await;
|
||||
if let Some(mut call) = calls.remove(id) {
|
||||
call.status = crate::state::CallStatus::Ended;
|
||||
call.ended_at = Some(now);
|
||||
let _ = state_clone.db.calls.insert(
|
||||
id.as_bytes(),
|
||||
serde_json::to_vec(&call).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
tracing::info!("Call {} ended", id);
|
||||
}
|
||||
_ => {} // Ringing, Busy, IceCandidate — route opaquely
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver via local WS, federation, or queue in DB
|
||||
state_clone.deliver_or_queue(&to_fp, message).await;
|
||||
|
||||
tracing::debug!("WS {}: routed message to {}", fp_clone2, to_fp);
|
||||
}
|
||||
@@ -147,10 +238,8 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
}
|
||||
}
|
||||
|
||||
if !state_clone.push_to_client(&to_fp, &message).await {
|
||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||
let _ = state_clone.db.messages.insert(key.as_bytes(), message);
|
||||
}
|
||||
// Deliver via local WS, federation, or queue in DB
|
||||
state_clone.deliver_or_queue(&to_fp, &message).await;
|
||||
|
||||
// Renew alias TTL
|
||||
crate::routes::messages::renew_alias_ttl(
|
||||
@@ -181,9 +270,9 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
// We can't easily get the sender ref here, so just clean up by fingerprint
|
||||
// In production, use a unique connection ID
|
||||
let mut conns = state.connections.lock().await;
|
||||
if let Some(senders) = conns.get_mut(&fingerprint) {
|
||||
senders.retain(|s| !s.is_closed());
|
||||
if senders.is_empty() {
|
||||
if let Some(devices) = conns.get_mut(&fingerprint) {
|
||||
devices.retain(|d| !d.sender.is_closed());
|
||||
if devices.is_empty() {
|
||||
conns.remove(&fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
45
warzone/crates/warzone-server/src/routes/wzp.rs
Normal file
45
warzone/crates/warzone-server/src/routes/wzp.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/wzp/relay-config", get(relay_config))
|
||||
}
|
||||
|
||||
/// Returns the WZP relay address and a short-lived service token.
|
||||
///
|
||||
/// The web client calls this to discover where to connect for voice/video
|
||||
/// and gets a token to present to the relay for authentication.
|
||||
async fn relay_config(
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
// Issue a short-lived service token (5 minutes) for WZP relay auth.
|
||||
let token = hex::encode(rand::random::<[u8; 32]>());
|
||||
let expires = chrono::Utc::now().timestamp() + 300; // 5 minutes
|
||||
|
||||
state.db.tokens.insert(
|
||||
token.as_bytes(),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"fingerprint": "service:wzp",
|
||||
"service": "wzp",
|
||||
"expires_at": expires,
|
||||
}))?.as_slice(),
|
||||
)?;
|
||||
|
||||
// The relay address is configured server-side. For now, return a
|
||||
// placeholder that the admin sets via environment variable.
|
||||
let relay_addr = std::env::var("WZP_RELAY_ADDR")
|
||||
.unwrap_or_else(|_| "127.0.0.1:4433".to_string());
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"relay_addr": relay_addr,
|
||||
"token": token,
|
||||
"expires_in": 300,
|
||||
})))
|
||||
}
|
||||
@@ -4,14 +4,26 @@ use tokio::sync::{Mutex, mpsc};
|
||||
|
||||
use crate::db::Database;
|
||||
|
||||
/// Maximum WebSocket connections per fingerprint (multi-device cap).
|
||||
const MAX_WS_PER_FINGERPRINT: usize = 5;
|
||||
|
||||
/// Maximum number of message IDs to track for deduplication.
|
||||
const DEDUP_CAPACITY: usize = 10_000;
|
||||
|
||||
/// Per-connection sender: messages are pushed here for instant delivery.
|
||||
pub type WsSender = mpsc::UnboundedSender<Vec<u8>>;
|
||||
|
||||
/// Connected clients: fingerprint → list of WS senders (multiple devices).
|
||||
pub type Connections = Arc<Mutex<HashMap<String, Vec<WsSender>>>>;
|
||||
/// Metadata for a single connected device.
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceConnection {
|
||||
pub device_id: String,
|
||||
pub sender: WsSender,
|
||||
pub connected_at: i64,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// Connected clients: fingerprint → list of device connections (multiple devices).
|
||||
pub type Connections = Arc<Mutex<HashMap<String, Vec<DeviceConnection>>>>;
|
||||
|
||||
/// Bounded dedup tracker: FIFO eviction when capacity is exceeded.
|
||||
#[derive(Clone)]
|
||||
@@ -47,11 +59,36 @@ impl DedupTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Call lifecycle status.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum CallStatus {
|
||||
Ringing,
|
||||
Active,
|
||||
Ended,
|
||||
}
|
||||
|
||||
/// Server-side state for an active or recently ended call.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CallState {
|
||||
pub call_id: String,
|
||||
pub caller_fp: String,
|
||||
pub callee_fp: String,
|
||||
pub group_name: Option<String>,
|
||||
pub room_id: Option<String>,
|
||||
pub status: CallStatus,
|
||||
pub created_at: i64,
|
||||
pub answered_at: Option<i64>,
|
||||
pub ended_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Arc<Database>,
|
||||
pub connections: Connections,
|
||||
pub dedup: DedupTracker,
|
||||
pub active_calls: Arc<Mutex<HashMap<String, CallState>>>,
|
||||
pub federation: Option<crate::federation::FederationHandle>,
|
||||
pub bots_enabled: bool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -61,16 +98,19 @@ impl AppState {
|
||||
db: Arc::new(db),
|
||||
connections: Arc::new(Mutex::new(HashMap::new())),
|
||||
dedup: DedupTracker::new(),
|
||||
active_calls: Arc::new(Mutex::new(HashMap::new())),
|
||||
federation: None,
|
||||
bots_enabled: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Try to push a message to a connected client. Returns true if delivered.
|
||||
pub async fn push_to_client(&self, fingerprint: &str, message: &[u8]) -> bool {
|
||||
let conns = self.connections.lock().await;
|
||||
if let Some(senders) = conns.get(fingerprint) {
|
||||
if let Some(devices) = conns.get(fingerprint) {
|
||||
let mut delivered = false;
|
||||
for sender in senders {
|
||||
if sender.send(message.to_vec()).is_ok() {
|
||||
for device in devices {
|
||||
if device.sender.send(message.to_vec()).is_ok() {
|
||||
delivered = true;
|
||||
}
|
||||
}
|
||||
@@ -81,25 +121,155 @@ impl AppState {
|
||||
}
|
||||
|
||||
/// Register a WS connection for a fingerprint.
|
||||
pub async fn register_ws(&self, fingerprint: &str) -> mpsc::UnboundedReceiver<Vec<u8>> {
|
||||
///
|
||||
/// Returns `None` if the per-fingerprint connection cap has been reached.
|
||||
/// On success, returns the assigned device ID and a receiver for push messages.
|
||||
pub async fn register_ws(&self, fingerprint: &str, token: Option<String>) -> Option<(String, mpsc::UnboundedReceiver<Vec<u8>>)> {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let device_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let mut conns = self.connections.lock().await;
|
||||
conns.entry(fingerprint.to_string()).or_default().push(tx);
|
||||
tracing::info!("WS registered for {} ({} total connections)", fingerprint,
|
||||
conns.values().map(|v| v.len()).sum::<usize>());
|
||||
rx
|
||||
let entry = conns.entry(fingerprint.to_string()).or_default();
|
||||
|
||||
// Clean up closed connections first
|
||||
entry.retain(|d| !d.sender.is_closed());
|
||||
|
||||
if entry.len() >= MAX_WS_PER_FINGERPRINT {
|
||||
tracing::warn!(
|
||||
"WS connection cap reached for {} ({} connections)",
|
||||
fingerprint,
|
||||
entry.len()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
entry.push(DeviceConnection {
|
||||
device_id: device_id.clone(),
|
||||
sender: tx,
|
||||
connected_at: chrono::Utc::now().timestamp(),
|
||||
token,
|
||||
});
|
||||
tracing::info!(
|
||||
"WS registered for {} device={} ({} total)",
|
||||
fingerprint,
|
||||
device_id,
|
||||
conns.values().map(|v| v.len()).sum::<usize>()
|
||||
);
|
||||
Some((device_id, rx))
|
||||
}
|
||||
|
||||
/// Unregister a WS connection.
|
||||
#[allow(dead_code)]
|
||||
pub async fn unregister_ws(&self, fingerprint: &str, sender: &WsSender) {
|
||||
let mut conns = self.connections.lock().await;
|
||||
if let Some(senders) = conns.get_mut(fingerprint) {
|
||||
senders.retain(|s| !s.same_channel(sender));
|
||||
if senders.is_empty() {
|
||||
if let Some(devices) = conns.get_mut(fingerprint) {
|
||||
devices.retain(|d| !d.sender.same_channel(sender));
|
||||
if devices.is_empty() {
|
||||
conns.remove(fingerprint);
|
||||
}
|
||||
}
|
||||
tracing::info!("WS unregistered for {}", fingerprint);
|
||||
}
|
||||
|
||||
/// Try to deliver a message: local push → federation forward → DB queue.
|
||||
/// Returns true if delivered instantly (local or remote).
|
||||
pub async fn deliver_or_queue(&self, to_fp: &str, message: &[u8]) -> bool {
|
||||
// BotFather: intercept messages to @botfather
|
||||
if self.bots_enabled && to_fp == "00000000000000000b0ffa00e000000f" {
|
||||
// Extract sender from message
|
||||
if let Ok(msg) = serde_json::from_slice::<serde_json::Value>(message) {
|
||||
let from = msg.get("from").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if !from.is_empty() {
|
||||
if crate::botfather::handle_botfather_message(self, from, message).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Try local WebSocket push
|
||||
if self.push_to_client(to_fp, message).await {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Try federation forward
|
||||
if let Some(ref federation) = self.federation {
|
||||
if federation.is_remote(to_fp).await {
|
||||
if federation.forward_message(to_fp, message).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Queue in local DB
|
||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||
let _ = self.db.messages.insert(key.as_bytes(), message);
|
||||
|
||||
// 4. Try bot webhook delivery (async, does not block the caller)
|
||||
{
|
||||
let state = self.clone();
|
||||
let fp = to_fp.to_string();
|
||||
let queue_key = key.clone();
|
||||
let msg = message.to_vec();
|
||||
tokio::spawn(async move {
|
||||
if crate::routes::bot::try_bot_webhook(&state, &fp, &msg).await {
|
||||
// Webhook accepted -- remove from offline queue
|
||||
let _ = state.db.messages.remove(queue_key.as_bytes());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a fingerprint has any active WS connections.
|
||||
pub async fn is_online(&self, fingerprint: &str) -> bool {
|
||||
let conns = self.connections.lock().await;
|
||||
conns.get(fingerprint).map(|d| !d.is_empty()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Count active WS connections for a fingerprint (multi-device).
|
||||
pub async fn device_count(&self, fingerprint: &str) -> usize {
|
||||
let conns = self.connections.lock().await;
|
||||
conns.get(fingerprint).map(|d| d.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// List devices for a fingerprint with metadata.
|
||||
pub async fn list_devices(&self, fingerprint: &str) -> Vec<(String, i64)> {
|
||||
let conns = self.connections.lock().await;
|
||||
conns.get(fingerprint)
|
||||
.map(|devices| devices.iter().map(|d| (d.device_id.clone(), d.connected_at)).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Kick a specific device by ID. Returns true if found and kicked.
|
||||
pub async fn kick_device(&self, fingerprint: &str, device_id: &str) -> bool {
|
||||
let mut conns = self.connections.lock().await;
|
||||
if let Some(devices) = conns.get_mut(fingerprint) {
|
||||
let before = devices.len();
|
||||
devices.retain(|d| d.device_id != device_id);
|
||||
let kicked = devices.len() < before;
|
||||
if devices.is_empty() {
|
||||
conns.remove(fingerprint);
|
||||
}
|
||||
kicked
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke all connections for a fingerprint except one device_id.
|
||||
pub async fn revoke_all_except(&self, fingerprint: &str, keep_device_id: &str) -> usize {
|
||||
let mut conns = self.connections.lock().await;
|
||||
if let Some(devices) = conns.get_mut(fingerprint) {
|
||||
let before = devices.len();
|
||||
devices.retain(|d| d.device_id == keep_device_id);
|
||||
let removed = before - devices.len();
|
||||
if devices.is_empty() {
|
||||
conns.remove(fingerprint);
|
||||
}
|
||||
removed
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ name = "warzone-wasm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@ impl WasmIdentity {
|
||||
Seed::from_bytes(self.seed_bytes).to_mnemonic()
|
||||
}
|
||||
|
||||
/// Get the Ethereum address derived from this seed.
|
||||
pub fn eth_address(&self) -> String {
|
||||
let eth = warzone_protocol::ethereum::derive_eth_identity(&self.seed_bytes);
|
||||
eth.address.to_checksum()
|
||||
}
|
||||
|
||||
/// Get the pre-key bundle as bincode bytes (for server registration).
|
||||
/// The bundle is generated once and cached. The SPK secret is stored internally.
|
||||
pub fn bundle_bytes(&mut self) -> Result<Vec<u8>, JsValue> {
|
||||
@@ -132,6 +138,9 @@ impl WasmIdentity {
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmSession {
|
||||
ratchet: RatchetState,
|
||||
/// Stored X3DH result from initiate() — needed for encrypt_key_exchange
|
||||
x3dh_ephemeral_public: Option<[u8; 32]>,
|
||||
x3dh_used_otpk_id: Option<u32>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
@@ -147,6 +156,8 @@ impl WasmSession {
|
||||
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
|
||||
Ok(WasmSession {
|
||||
ratchet: RatchetState::init_alice(result.shared_secret, their_spk),
|
||||
x3dh_ephemeral_public: Some(*result.ephemeral_public.as_bytes()),
|
||||
x3dh_used_otpk_id: result.used_one_time_pre_key_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,14 +173,14 @@ impl WasmSession {
|
||||
pub fn encrypt_key_exchange_with_id(
|
||||
&mut self,
|
||||
identity: &WasmIdentity,
|
||||
their_bundle_bytes: &[u8],
|
||||
_their_bundle_bytes: &[u8],
|
||||
plaintext: &str,
|
||||
msg_id: &str,
|
||||
) -> Result<Vec<u8>, JsValue> {
|
||||
let bundle: PreKeyBundle = bincode::deserialize(their_bundle_bytes)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
let result = x3dh::initiate(&identity.identity, &bundle)
|
||||
.map_err(|e| JsValue::from_str(&format!("X3DH: {}", e)))?;
|
||||
// Use the stored X3DH result from initiate() — DO NOT re-initiate
|
||||
// (re-initiating generates a new ephemeral key that doesn't match the ratchet)
|
||||
let ephemeral_public = self.x3dh_ephemeral_public
|
||||
.ok_or_else(|| JsValue::from_str("no X3DH result — call initiate() first"))?;
|
||||
|
||||
let encrypted = self.ratchet.encrypt(plaintext.as_bytes())
|
||||
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
|
||||
@@ -178,8 +189,8 @@ impl WasmSession {
|
||||
id: msg_id.to_string(),
|
||||
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
|
||||
sender_identity_encryption_key: *identity.pub_id.encryption.as_bytes(),
|
||||
ephemeral_public: *result.ephemeral_public.as_bytes(),
|
||||
used_one_time_pre_key_id: result.used_one_time_pre_key_id,
|
||||
ephemeral_public,
|
||||
used_one_time_pre_key_id: self.x3dh_used_otpk_id,
|
||||
ratchet_message: encrypted,
|
||||
};
|
||||
bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
@@ -201,16 +212,17 @@ impl WasmSession {
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<String, JsValue> {
|
||||
let bytes = bincode::serialize(&self.ratchet).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
let bytes = self.ratchet.serialize_versioned()
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
Ok(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes))
|
||||
}
|
||||
|
||||
pub fn restore(data: &str) -> Result<WasmSession, JsValue> {
|
||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
let ratchet: RatchetState = bincode::deserialize(&bytes)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
Ok(WasmSession { ratchet })
|
||||
let ratchet = RatchetState::deserialize_versioned(&bytes)
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
Ok(WasmSession { ratchet, x3dh_ephemeral_public: None, x3dh_used_otpk_id: None })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +373,7 @@ pub fn decrypt_wire_message(
|
||||
let seed = Seed::from_bytes(sb);
|
||||
let id = seed.derive_identity();
|
||||
|
||||
let wire: WireMessage = bincode::deserialize(message_bytes)
|
||||
let wire: WireMessage = warzone_protocol::message::deserialize_envelope(message_bytes)
|
||||
.map_err(|e| JsValue::from_str(&format!("deserialize wire: {}", e)))?;
|
||||
|
||||
match wire {
|
||||
@@ -392,7 +404,7 @@ pub fn decrypt_wire_message(
|
||||
|
||||
let session_b64 = base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&bincode::serialize(&ratchet).unwrap_or_default(),
|
||||
&ratchet.serialize_versioned().unwrap_or_default(),
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
@@ -413,15 +425,15 @@ pub fn decrypt_wire_message(
|
||||
let session_bytes = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD, &session_data,
|
||||
).map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
let mut ratchet: RatchetState = bincode::deserialize(&session_bytes)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
let mut ratchet = RatchetState::deserialize_versioned(&session_bytes)
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
let plain = ratchet.decrypt(&ratchet_message)
|
||||
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
|
||||
|
||||
let session_b64 = base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&bincode::serialize(&ratchet).unwrap_or_default(),
|
||||
&ratchet.serialize_versioned().unwrap_or_default(),
|
||||
);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
@@ -474,10 +486,307 @@ pub fn decrypt_wire_message(
|
||||
"data": hex::encode(&data),
|
||||
}).to_string())
|
||||
}
|
||||
_ => {
|
||||
WireMessage::SenderKeyDistribution {
|
||||
sender_fingerprint,
|
||||
group_name,
|
||||
chain_key,
|
||||
generation,
|
||||
} => {
|
||||
// Return the distribution data so JS can store it
|
||||
Ok(serde_json::json!({
|
||||
"type": "unsupported",
|
||||
"type": "sender_key_distribution",
|
||||
"sender": sender_fingerprint,
|
||||
"group": group_name,
|
||||
"chain_key": hex::encode(chain_key),
|
||||
"generation": generation,
|
||||
}).to_string())
|
||||
}
|
||||
WireMessage::GroupSenderKey {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
group_name,
|
||||
generation,
|
||||
counter,
|
||||
ciphertext,
|
||||
} => {
|
||||
// Return the encrypted group message data so JS can decrypt with stored sender key
|
||||
// JS must call a separate decrypt function with the sender key
|
||||
Ok(serde_json::json!({
|
||||
"type": "group_message",
|
||||
"id": id,
|
||||
"sender": sender_fingerprint,
|
||||
"group": group_name,
|
||||
"generation": generation,
|
||||
"counter": counter,
|
||||
"ciphertext": hex::encode(&ciphertext),
|
||||
}).to_string())
|
||||
}
|
||||
WireMessage::CallSignal {
|
||||
id,
|
||||
sender_fingerprint,
|
||||
signal_type,
|
||||
payload,
|
||||
target,
|
||||
} => {
|
||||
let type_str = match signal_type {
|
||||
warzone_protocol::message::CallSignalType::Offer => "offer",
|
||||
warzone_protocol::message::CallSignalType::Answer => "answer",
|
||||
warzone_protocol::message::CallSignalType::IceCandidate => "ice_candidate",
|
||||
warzone_protocol::message::CallSignalType::Hangup => "hangup",
|
||||
warzone_protocol::message::CallSignalType::Reject => "reject",
|
||||
warzone_protocol::message::CallSignalType::Ringing => "ringing",
|
||||
warzone_protocol::message::CallSignalType::Busy => "busy",
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"type": "call_signal",
|
||||
"id": id,
|
||||
"sender": sender_fingerprint,
|
||||
"signal_type": type_str,
|
||||
"payload": payload,
|
||||
"target": target,
|
||||
}).to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt a group message using a stored sender key.
|
||||
///
|
||||
/// Arguments:
|
||||
/// - sender_key_hex: hex-encoded bincode-serialized SenderKey (from sender_key_distribution)
|
||||
/// - sender_fingerprint, group_name, generation, counter, ciphertext_hex: from the group_message JSON
|
||||
///
|
||||
/// Returns JSON: { "text": "...", "sender_key": "updated_hex" }
|
||||
#[wasm_bindgen]
|
||||
pub fn decrypt_group_message(
|
||||
sender_key_hex: &str,
|
||||
sender_fingerprint: &str,
|
||||
group_name: &str,
|
||||
generation: u32,
|
||||
counter: u32,
|
||||
ciphertext_hex: &str,
|
||||
) -> Result<String, JsValue> {
|
||||
use warzone_protocol::sender_keys::{SenderKey, SenderKeyMessage};
|
||||
|
||||
let key_bytes = hex::decode(sender_key_hex)
|
||||
.map_err(|e| JsValue::from_str(&format!("invalid sender key hex: {}", e)))?;
|
||||
let mut sender_key: SenderKey = bincode::deserialize(&key_bytes)
|
||||
.map_err(|e| JsValue::from_str(&format!("deserialize sender key: {}", e)))?;
|
||||
|
||||
let ciphertext = hex::decode(ciphertext_hex)
|
||||
.map_err(|e| JsValue::from_str(&format!("invalid ciphertext hex: {}", e)))?;
|
||||
|
||||
let msg = SenderKeyMessage {
|
||||
sender_fingerprint: sender_fingerprint.to_string(),
|
||||
group_name: group_name.to_string(),
|
||||
generation,
|
||||
counter,
|
||||
ciphertext,
|
||||
};
|
||||
|
||||
let plaintext = sender_key.decrypt(&msg)
|
||||
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
|
||||
|
||||
// Return updated sender key (counter advanced) so JS can persist it
|
||||
let updated_key = bincode::serialize(&sender_key).unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"text": String::from_utf8_lossy(&plaintext),
|
||||
"sender_key": hex::encode(updated_key),
|
||||
}).to_string())
|
||||
}
|
||||
|
||||
/// Create a sender key from a distribution message.
|
||||
///
|
||||
/// Takes the fields from a sender_key_distribution JSON and returns
|
||||
/// a hex-encoded bincode SenderKey that JS should store.
|
||||
#[wasm_bindgen]
|
||||
pub fn create_sender_key_from_distribution(
|
||||
sender_fingerprint: &str,
|
||||
group_name: &str,
|
||||
chain_key_hex: &str,
|
||||
generation: u32,
|
||||
) -> Result<String, JsValue> {
|
||||
use warzone_protocol::sender_keys::SenderKeyDistribution;
|
||||
|
||||
let chain_key_bytes = hex::decode(chain_key_hex)
|
||||
.map_err(|e| JsValue::from_str(&format!("invalid chain key hex: {}", e)))?;
|
||||
let mut chain_key = [0u8; 32];
|
||||
if chain_key_bytes.len() != 32 {
|
||||
return Err(JsValue::from_str("chain key must be 32 bytes"));
|
||||
}
|
||||
chain_key.copy_from_slice(&chain_key_bytes);
|
||||
|
||||
let dist = SenderKeyDistribution {
|
||||
sender_fingerprint: sender_fingerprint.to_string(),
|
||||
group_name: group_name.to_string(),
|
||||
chain_key,
|
||||
generation,
|
||||
};
|
||||
|
||||
let sender_key = dist.into_sender_key();
|
||||
let encoded = bincode::serialize(&sender_key).unwrap_or_default();
|
||||
Ok(hex::encode(encoded))
|
||||
}
|
||||
|
||||
/// Create a CallSignal WireMessage for sending via WebSocket.
|
||||
///
|
||||
/// Arguments:
|
||||
/// - identity: the WasmIdentity of the sender
|
||||
/// - signal_type: "offer" | "answer" | "ice_candidate" | "hangup" | "reject" | "ringing" | "busy"
|
||||
/// - payload: SDP offer/answer, ICE candidate JSON, or empty string
|
||||
/// - target: recipient fingerprint or group name
|
||||
///
|
||||
/// Returns: bincode-serialized WireMessage bytes
|
||||
#[wasm_bindgen]
|
||||
pub fn create_call_signal(
|
||||
identity: &WasmIdentity,
|
||||
signal_type: &str,
|
||||
payload: &str,
|
||||
target: &str,
|
||||
) -> Result<Vec<u8>, JsValue> {
|
||||
use warzone_protocol::message::{CallSignalType, WireMessage};
|
||||
|
||||
let st = match signal_type.to_lowercase().as_str() {
|
||||
"offer" => CallSignalType::Offer,
|
||||
"answer" => CallSignalType::Answer,
|
||||
"ice_candidate" | "icecandidate" => CallSignalType::IceCandidate,
|
||||
"hangup" => CallSignalType::Hangup,
|
||||
"reject" => CallSignalType::Reject,
|
||||
"ringing" => CallSignalType::Ringing,
|
||||
"busy" => CallSignalType::Busy,
|
||||
_ => return Err(JsValue::from_str(&format!("unknown signal type: {}", signal_type))),
|
||||
};
|
||||
|
||||
let wire = WireMessage::CallSignal {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
|
||||
signal_type: st,
|
||||
payload: payload.to_string(),
|
||||
target: target.to_string(),
|
||||
};
|
||||
|
||||
bincode::serialize(&wire).map_err(|e| JsValue::from_str(&format!("serialize: {}", e)))
|
||||
}
|
||||
|
||||
// Tests live in warzone-protocol to avoid js-sys dependency issues.
|
||||
// See warzone-protocol/src/x3dh.rs tests for web-client simulation.
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn web_client_to_web_client() {
|
||||
// === Alice (sender) ===
|
||||
let mut alice = WasmIdentity::new();
|
||||
let alice_seed = alice.seed_hex();
|
||||
let alice_spk = alice.spk_secret_hex();
|
||||
let alice_bundle = alice.bundle_bytes().unwrap();
|
||||
|
||||
// === Bob (receiver) ===
|
||||
let mut bob = WasmIdentity::new();
|
||||
let bob_seed = bob.seed_hex();
|
||||
let bob_spk = bob.spk_secret_hex();
|
||||
let bob_bundle = bob.bundle_bytes().unwrap();
|
||||
|
||||
println!("Alice fp: {}", alice.fingerprint());
|
||||
println!("Bob fp: {}", bob.fingerprint());
|
||||
println!("Alice SPK secret: {}...", &alice_spk[..16]);
|
||||
println!("Bob SPK secret: {}...", &bob_spk[..16]);
|
||||
|
||||
// === Alice sends to Bob (exactly like the web JS) ===
|
||||
// 1. Alice creates session from Bob's bundle
|
||||
let mut alice_session = WasmSession::initiate(&alice, &bob_bundle).unwrap();
|
||||
|
||||
// 2. Alice encrypts with key exchange
|
||||
let wire_bytes = alice_session
|
||||
.encrypt_key_exchange_with_id(&alice, &bob_bundle, "hello bob", "msg-001")
|
||||
.unwrap();
|
||||
|
||||
println!("Wire message size: {} bytes", wire_bytes.len());
|
||||
|
||||
// === Bob receives and decrypts (exactly like handleIncomingMessage) ===
|
||||
// First try: decrypt_wire_message with null session (handles KeyExchange)
|
||||
let result = decrypt_wire_message(&bob_seed, &bob_spk, &wire_bytes, None);
|
||||
|
||||
match result {
|
||||
Ok(json_str) => {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
|
||||
println!("Decrypt SUCCESS: {}", json_str);
|
||||
assert_eq!(parsed["text"].as_str().unwrap(), "hello bob");
|
||||
assert!(parsed["new_session"].as_bool().unwrap());
|
||||
println!("Session data present: {}", parsed["session_data"].as_str().is_some());
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("Decrypt FAILED: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that restored session (from base64) can decrypt subsequent messages.
|
||||
#[test]
|
||||
fn web_client_session_continuity() {
|
||||
let mut alice = WasmIdentity::new();
|
||||
let mut bob = WasmIdentity::new();
|
||||
let bob_seed = bob.seed_hex();
|
||||
let bob_spk = bob.spk_secret_hex();
|
||||
let bob_bundle = bob.bundle_bytes().unwrap();
|
||||
|
||||
// Alice sends first message (KeyExchange)
|
||||
let mut alice_session = WasmSession::initiate(&alice, &bob_bundle).unwrap();
|
||||
let wire1 = alice_session
|
||||
.encrypt_key_exchange_with_id(&alice, &bob_bundle, "msg one", "id-1")
|
||||
.unwrap();
|
||||
|
||||
// Bob decrypts first message
|
||||
let result1 = decrypt_wire_message(&bob_seed, &bob_spk, &wire1, None).unwrap();
|
||||
let parsed1: serde_json::Value = serde_json::from_str(&result1).unwrap();
|
||||
assert_eq!(parsed1["text"].as_str().unwrap(), "msg one");
|
||||
let bob_session_data = parsed1["session_data"].as_str().unwrap().to_string();
|
||||
|
||||
// Alice sends second message (regular Message, not KeyExchange)
|
||||
let alice_session_data = alice_session.save().unwrap();
|
||||
let mut alice_session2 = WasmSession::restore(&alice_session_data).unwrap();
|
||||
let wire2 = alice_session2
|
||||
.encrypt_with_id(&alice, "msg two", "id-2")
|
||||
.unwrap();
|
||||
|
||||
// Bob decrypts second message using saved session
|
||||
let result2 = decrypt_wire_message(&bob_seed, &bob_spk, &wire2, Some(bob_session_data)).unwrap();
|
||||
let parsed2: serde_json::Value = serde_json::from_str(&result2).unwrap();
|
||||
assert_eq!(parsed2["text"].as_str().unwrap(), "msg two");
|
||||
}
|
||||
|
||||
/// Test bidirectional: Alice sends to Bob, Bob sends to Alice.
|
||||
#[test]
|
||||
fn web_client_bidirectional() {
|
||||
let mut alice = WasmIdentity::new();
|
||||
let alice_seed = alice.seed_hex();
|
||||
let alice_spk = alice.spk_secret_hex();
|
||||
let alice_bundle = alice.bundle_bytes().unwrap();
|
||||
|
||||
let mut bob = WasmIdentity::new();
|
||||
let bob_seed = bob.seed_hex();
|
||||
let bob_spk = bob.spk_secret_hex();
|
||||
let bob_bundle = bob.bundle_bytes().unwrap();
|
||||
|
||||
// Alice → Bob
|
||||
let mut a_session = WasmSession::initiate(&alice, &bob_bundle).unwrap();
|
||||
let wire_a2b = a_session
|
||||
.encrypt_key_exchange_with_id(&alice, &bob_bundle, "hi bob", "a1")
|
||||
.unwrap();
|
||||
let r1 = decrypt_wire_message(&bob_seed, &bob_spk, &wire_a2b, None).unwrap();
|
||||
let p1: serde_json::Value = serde_json::from_str(&r1).unwrap();
|
||||
assert_eq!(p1["text"].as_str().unwrap(), "hi bob");
|
||||
|
||||
// Bob → Alice
|
||||
let mut b_session = WasmSession::initiate(&bob, &alice_bundle).unwrap();
|
||||
let wire_b2a = b_session
|
||||
.encrypt_key_exchange_with_id(&bob, &alice_bundle, "hi alice", "b1")
|
||||
.unwrap();
|
||||
let r2 = decrypt_wire_message(&alice_seed, &alice_spk, &wire_b2a, None).unwrap();
|
||||
let p2: serde_json::Value = serde_json::from_str(&r2).unwrap();
|
||||
assert_eq!(p2["text"].as_str().unwrap(), "hi alice");
|
||||
}
|
||||
}
|
||||
|
||||
8
warzone/deploy/federation-kh3rad3ree.json
Normal file
8
warzone/deploy/federation-kh3rad3ree.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"server_id": "kh3rad3ree",
|
||||
"shared_secret": "7cfe41395062d939a36d9debe7d70f528ccd2efaccddca139c19603fe40df8f4",
|
||||
"peer": {
|
||||
"id": "mequ",
|
||||
"url": "http://10.66.66.129:7700"
|
||||
}
|
||||
}
|
||||
8
warzone/deploy/federation-mequ.json
Normal file
8
warzone/deploy/federation-mequ.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"server_id": "mequ",
|
||||
"shared_secret": "7cfe41395062d939a36d9debe7d70f528ccd2efaccddca139c19603fe40df8f4",
|
||||
"peer": {
|
||||
"id": "kh3rad3ree",
|
||||
"url": "http://10.66.66.253:7700"
|
||||
}
|
||||
}
|
||||
6
warzone/deploy/journald-warzone.conf
Normal file
6
warzone/deploy/journald-warzone.conf
Normal file
@@ -0,0 +1,6 @@
|
||||
# /etc/systemd/journald.conf.d/warzone.conf
|
||||
# Cap journal storage to avoid filling disk on mequ
|
||||
[Journal]
|
||||
SystemMaxUse=50M
|
||||
SystemMaxFileSize=10M
|
||||
MaxRetentionSec=7day
|
||||
60
warzone/deploy/setup.sh
Executable file
60
warzone/deploy/setup.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Setup script — run as root on each server.
|
||||
# Usage: ./setup.sh <mequ|kh3rad3ree>
|
||||
|
||||
HOSTNAME="${1:-}"
|
||||
if [ -z "$HOSTNAME" ] || { [ "$HOSTNAME" != "mequ" ] && [ "$HOSTNAME" != "kh3rad3ree" ]; }; then
|
||||
echo "Usage: $0 <mequ|kh3rad3ree>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Setting up featherChat on $HOSTNAME ==="
|
||||
|
||||
# Create warzone user if it doesn't exist
|
||||
if ! id warzone &>/dev/null; then
|
||||
echo "[1/4] Creating warzone user..."
|
||||
useradd -r -m -s /bin/bash warzone
|
||||
else
|
||||
echo "[1/4] User warzone already exists"
|
||||
fi
|
||||
|
||||
# Create data directory
|
||||
echo "[2/4] Creating directories..."
|
||||
mkdir -p /home/warzone/data
|
||||
chown -R warzone:warzone /home/warzone
|
||||
|
||||
# Copy binaries
|
||||
echo "[3/4] Installing binaries..."
|
||||
cp warzone-server warzone-client /home/warzone/
|
||||
chmod +x /home/warzone/warzone-server /home/warzone/warzone-client
|
||||
cp "federation-${HOSTNAME}.json" /home/warzone/federation.json
|
||||
chown warzone:warzone /home/warzone/warzone-server /home/warzone/warzone-client /home/warzone/federation.json
|
||||
|
||||
# Copy environment file
|
||||
if [ -f "warzone-server.env.${HOSTNAME}" ]; then
|
||||
cp "warzone-server.env.${HOSTNAME}" /home/warzone/server.env
|
||||
chown warzone:warzone /home/warzone/server.env
|
||||
echo " Environment: $(cat /home/warzone/server.env | grep -v '^#' | grep .)"
|
||||
fi
|
||||
|
||||
# Install systemd service + journald log cap
|
||||
echo "[4/5] Installing systemd service..."
|
||||
cp warzone-server.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable warzone-server
|
||||
|
||||
echo "[5/5] Capping journal logs (50MB max, 7 day retention)..."
|
||||
mkdir -p /etc/systemd/journald.conf.d
|
||||
cp journald-warzone.conf /etc/systemd/journald.conf.d/warzone.conf
|
||||
systemctl restart systemd-journald
|
||||
# Vacuum existing logs
|
||||
journalctl --vacuum-size=50M 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo "Start: systemctl start warzone-server"
|
||||
echo "Status: systemctl status warzone-server"
|
||||
echo "Logs: journalctl -u warzone-server -f"
|
||||
echo "Stop: systemctl stop warzone-server"
|
||||
2
warzone/deploy/warzone-server.env.kh3rad3ree
Normal file
2
warzone/deploy/warzone-server.env.kh3rad3ree
Normal file
@@ -0,0 +1,2 @@
|
||||
# kh3rad3ree: federation + bots enabled
|
||||
EXTRA_ARGS=--enable-bots
|
||||
2
warzone/deploy/warzone-server.env.mequ
Normal file
2
warzone/deploy/warzone-server.env.mequ
Normal file
@@ -0,0 +1,2 @@
|
||||
# mequ: federation only, no bots
|
||||
EXTRA_ARGS=
|
||||
28
warzone/deploy/warzone-server.service
Normal file
28
warzone/deploy/warzone-server.service
Normal file
@@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=Warzone Messenger Server (featherChat)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=warzone
|
||||
Group=warzone
|
||||
WorkingDirectory=/home/warzone
|
||||
EnvironmentFile=-/home/warzone/server.env
|
||||
ExecStart=/home/warzone/warzone-server --bind 0.0.0.0:7700 --data-dir /home/warzone/data --federation /home/warzone/federation.json $EXTRA_ARGS
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
LimitNOFILE=65536
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/warzone/data
|
||||
PrivateTmp=yes
|
||||
|
||||
# Environment — warn-only to minimize disk usage (set to info for debugging)
|
||||
Environment=RUST_LOG=warn,warzone_server::federation=info
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
File diff suppressed because it is too large
Load Diff
440
warzone/docs/BOT_API.md
Normal file
440
warzone/docs/BOT_API.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# featherChat Bot API
|
||||
|
||||
## Overview
|
||||
|
||||
featherChat exposes a **Telegram Bot API-compatible** HTTP interface, allowing
|
||||
developers to build bots that interact with featherChat users using familiar
|
||||
patterns. Bots are created exclusively through **@botfather**, receive a token,
|
||||
and communicate via long-polling or webhooks.
|
||||
|
||||
The server must be started with `--enable-bots` to activate bot functionality.
|
||||
|
||||
Key properties:
|
||||
|
||||
- **BotFather is required** -- only `@botfather` can register bots. It is
|
||||
auto-created on first server start (token printed in server logs).
|
||||
- Bot aliases **must** end with `Bot`, `bot`, or `_bot` (auto-enforced on
|
||||
registration).
|
||||
- Bots receive encrypted user messages as **base64 blobs** (`raw_encrypted`
|
||||
field) unless registered as E2E bots. Plaintext bot-to-bot messages are
|
||||
delivered with a readable `text` field.
|
||||
- Bot-sent messages are **plaintext** (not E2E encrypted) unless the bot is
|
||||
registered in E2E mode.
|
||||
- `chat_id` accepts both hex fingerprints and numeric IDs (Telegram
|
||||
compatibility). Numeric IDs are also returned in `from.id`.
|
||||
- Each bot has an `owner` field linking to the creating user's fingerprint.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```
|
||||
1. Message @botfather to create a bot (or use BotFather token from server logs).
|
||||
BotFather registers the bot via:
|
||||
POST /v1/bot/register
|
||||
{"name": "WeatherBot", "fingerprint": "aabbccdd...", "botfather_token": "<bf_token>"}
|
||||
|
||||
2. Extract the token from the response.
|
||||
|
||||
3. Poll for updates:
|
||||
POST /v1/bot/<token>/getUpdates
|
||||
{"timeout": 50}
|
||||
|
||||
4. Send a reply:
|
||||
POST /v1/bot/<token>/sendMessage
|
||||
{"chat_id": "<sender_fingerprint_or_numeric_id>", "text": "Hello!"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Register a Bot
|
||||
|
||||
```
|
||||
POST /v1/bot/register
|
||||
```
|
||||
|
||||
Creates a new bot, stores it in the server database, and auto-registers an
|
||||
alias. **Only @botfather can call this endpoint** -- a valid `botfather_token`
|
||||
is required.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "MyBot",
|
||||
"fingerprint": "aabbccdd1122334455667788aabbccdd",
|
||||
"botfather_token": "<botfather_token>",
|
||||
"owner": "<creator_fingerprint>"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------------|--------|---------------------------------------------------|
|
||||
| `name` | string | Display name. Alias suffix auto-added if needed. |
|
||||
| `fingerprint` | string | Hex-encoded public key fingerprint for the bot. |
|
||||
| `botfather_token` | string | BotFather authorization token (required). |
|
||||
| `owner` | string | Fingerprint of the user who requested creation. |
|
||||
|
||||
**E2E bot registration** (optional additional fields):
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------|--------|--------------------------------------------------|
|
||||
| `e2e` | bool | Set to `true` to register as an E2E bot. |
|
||||
| `bundle` | object | Full prekey bundle (identity_key, signed_prekey, signature, one_time_prekeys). |
|
||||
| `eth_address` | string | Ethereum address for the bot. |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": {
|
||||
"token": "aabbccdd11223344:9f8e7d6c5b4a39281706abcdef012345",
|
||||
"name": "MyBot",
|
||||
"fingerprint": "aabbccdd1122334455667788aabbccdd",
|
||||
"alias": "@mybot_bot",
|
||||
"owner": "<creator_fingerprint>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Token format:** `<first-16-chars-of-fingerprint>:<32-hex-random-bytes>`
|
||||
|
||||
**Alias rules:**
|
||||
|
||||
- If the name already ends with `Bot`, `bot`, or `_bot`, the alias is the
|
||||
lowercased name (e.g. `WeatherBot` -> `@weatherbot`).
|
||||
- Otherwise `_bot` is appended (e.g. `weather` -> `@weather_bot`).
|
||||
- The alias is registered in both directions (alias -> fingerprint and
|
||||
fingerprint -> alias).
|
||||
|
||||
---
|
||||
|
||||
### 2. Get Bot Info
|
||||
|
||||
```
|
||||
GET /v1/bot/:token/getMe
|
||||
```
|
||||
|
||||
Returns information about the bot in a Telegram-compatible shape.
|
||||
|
||||
**Response (valid token):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": {
|
||||
"id": "aabbccdd1122334455667788aabbccdd",
|
||||
"is_bot": true,
|
||||
"first_name": "MyBot",
|
||||
"username": "MyBot"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (invalid token):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"description": "invalid token"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Get Updates (Long-Poll)
|
||||
|
||||
```
|
||||
POST /v1/bot/:token/getUpdates
|
||||
```
|
||||
|
||||
Returns queued messages for the bot and deletes them from the queue.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"timeout": 5
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|------|------------------------------------------------------|
|
||||
| `timeout` | u64 | Optional. Long-poll wait in seconds. **Capped at 50.** |
|
||||
|
||||
If the queue is empty and `timeout > 0`, the server waits up to `timeout`
|
||||
seconds (max 50) before returning an empty result, giving new messages a chance
|
||||
to arrive.
|
||||
|
||||
> **Note:** If a webhook is configured via `setWebhook`, updates are delivered
|
||||
> live to the webhook URL via POST instead of being queued for polling.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": [ ...updates... ]
|
||||
}
|
||||
```
|
||||
|
||||
#### Update Types
|
||||
|
||||
**Encrypted message** (from a user — bot must decrypt if it has a session):
|
||||
|
||||
```json
|
||||
{
|
||||
"update_id": 1,
|
||||
"message": {
|
||||
"message_id": "uuid",
|
||||
"from": {
|
||||
"id": "sender_fingerprint",
|
||||
"is_bot": false,
|
||||
"first_name": "sender_finge"
|
||||
},
|
||||
"chat": {
|
||||
"id": "sender_fingerprint",
|
||||
"type": "private"
|
||||
},
|
||||
"date": 1711670400,
|
||||
"text": null,
|
||||
"raw_encrypted": "base64-encoded-wiremessage..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key exchange** (X3DH session initiation — same shape as encrypted message):
|
||||
|
||||
```json
|
||||
{
|
||||
"update_id": 2,
|
||||
"message": {
|
||||
"message_id": "uuid",
|
||||
"from": { "id": "sender_fp", "is_bot": false, "first_name": "sender_fp..." },
|
||||
"chat": { "id": "sender_fp", "type": "private" },
|
||||
"date": 1711670400,
|
||||
"text": null,
|
||||
"raw_encrypted": "base64-encoded-keyexchange..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Call signal:**
|
||||
|
||||
```json
|
||||
{
|
||||
"update_id": 3,
|
||||
"message": {
|
||||
"message_id": "uuid",
|
||||
"from": { "id": "sender_fp", "is_bot": false, "first_name": "sender_fp..." },
|
||||
"chat": { "id": "sender_fp", "type": "private" },
|
||||
"date": 1711670400,
|
||||
"text": "/call_Offer",
|
||||
"call_signal": {
|
||||
"type": "Offer",
|
||||
"payload": "SDP or ICE data..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**File header:**
|
||||
|
||||
```json
|
||||
{
|
||||
"update_id": 4,
|
||||
"message": {
|
||||
"message_id": "uuid",
|
||||
"from": { "id": "sender_fp", "is_bot": false, "first_name": "sender_fp..." },
|
||||
"chat": { "id": "sender_fp", "type": "private" },
|
||||
"date": 1711670400,
|
||||
"document": {
|
||||
"file_name": "report.pdf",
|
||||
"file_size": 204800
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Bot message (plaintext, from another bot via `sendMessage`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"update_id": 5,
|
||||
"message": {
|
||||
"message_id": "uuid",
|
||||
"from": {
|
||||
"id": "other_bot_fingerprint",
|
||||
"is_bot": true
|
||||
},
|
||||
"chat": {
|
||||
"id": "other_bot_fingerprint",
|
||||
"type": "private"
|
||||
},
|
||||
"date": 1711670400,
|
||||
"text": "Hello from the other bot!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** Receipt and internal wire messages (FileChunk, GroupSenderKey,
|
||||
> SenderKeyDistribution) are silently skipped and never delivered as updates.
|
||||
|
||||
---
|
||||
|
||||
### 4. Send Message
|
||||
|
||||
```
|
||||
POST /v1/bot/:token/sendMessage
|
||||
```
|
||||
|
||||
Sends a **plaintext** message to a user or another bot.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"chat_id": "aabbccdd1122334455667788aabbccdd",
|
||||
"text": "Hello from MyBot!"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|--------|-----------------------------------------------------------|
|
||||
| `chat_id` | string/int | Recipient fingerprint (hex), Ethereum address, or numeric ID. |
|
||||
| `text` | string | Message body. |
|
||||
| `parse_mode` | string | Optional. `"HTML"` renders basic tags (<b>, <i>, <code>, <a>). |
|
||||
|
||||
`chat_id` accepts hex fingerprint strings, Ethereum addresses, or numeric
|
||||
integer IDs (Telegram compatibility). Non-hex characters in string chat_ids are
|
||||
stripped and the value is lowercased before routing.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": {
|
||||
"message_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"chat": {
|
||||
"id": "aabbccdd1122334455667788aabbccdd",
|
||||
"type": "private"
|
||||
},
|
||||
"text": "Hello from MyBot!",
|
||||
"date": 1711670400,
|
||||
"delivered": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `delivered` field indicates whether the message was sent over a live
|
||||
WebSocket connection (`true`) or queued for later retrieval (`false`).
|
||||
|
||||
---
|
||||
|
||||
## Alias Rules
|
||||
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| Bot aliases **must** end with `Bot`, `bot`, or `_bot` | Enforced at registration time. |
|
||||
| Non-bot users **cannot** register aliases with these suffixes | Reserved for bots. |
|
||||
| Auto-registered on bot creation | No separate alias step needed. |
|
||||
| Users message bots via alias | e.g. `@mybot_bot`, resolved like any other alias. |
|
||||
|
||||
---
|
||||
|
||||
## Differences from Telegram Bot API
|
||||
|
||||
| Feature | Telegram | featherChat |
|
||||
|---------|----------|-------------|
|
||||
| `chat_id` type | Numeric integer | Hex fingerprint string or numeric integer (both accepted) |
|
||||
| `getUpdates` timeout | Up to 50s | Capped at **50s** |
|
||||
| Message content | Always plaintext | Encrypted messages arrive as `raw_encrypted` base64; E2E bots can decrypt |
|
||||
| Bot-sent messages | Plaintext | Plaintext by default; E2E mode available |
|
||||
| `from.id` | Numeric integer | Numeric integer (`from.id_str` has hex fingerprint) |
|
||||
| `parse_mode` | Renders HTML/Markdown | HTML rendered (<b>, <i>, <code>, <a>) |
|
||||
| Inline keyboards / callback queries | Supported | Stored + delivered, no popup |
|
||||
| Webhooks (`setWebhook`) | Supported | Implemented -- updates delivered live to webhook URL |
|
||||
| Media groups | Supported | Not yet (planned) |
|
||||
| File download (`getFile`) | Supported | Not yet (planned) |
|
||||
|
||||
---
|
||||
|
||||
## Example: Simple Echo Bot (Python)
|
||||
|
||||
```python
|
||||
import requests
|
||||
import time
|
||||
|
||||
TOKEN = "your_bot_token"
|
||||
API = f"http://localhost:7700/v1/bot/{TOKEN}"
|
||||
|
||||
while True:
|
||||
resp = requests.post(f"{API}/getUpdates", json={"timeout": 50}).json()
|
||||
for update in resp.get("result", []):
|
||||
msg = update.get("message", {})
|
||||
text = msg.get("text") or "[encrypted]"
|
||||
chat_id = msg.get("chat", {}).get("id", "")
|
||||
if text and chat_id:
|
||||
requests.post(f"{API}/sendMessage", json={
|
||||
"chat_id": chat_id,
|
||||
"text": f"Echo: {text}",
|
||||
})
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
### Example: Registration (curl)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7700/v1/bot/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "EchoBot", "fingerprint": "aabbccdd1122334455667788aabbccdd"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
All bot endpoints (except `/register`) are authenticated by the **token** in
|
||||
the URL path. Tokens are generated at registration time and stored server-side.
|
||||
There is no expiration mechanism in v1 -- tokens remain valid until the server
|
||||
database is cleared.
|
||||
|
||||
The token grants full access to poll and send messages as the bot. **Treat it
|
||||
like a password.**
|
||||
|
||||
---
|
||||
|
||||
## Internal Details
|
||||
|
||||
- Bot info is stored in the `tokens` sled tree under key `bot:<token>`.
|
||||
- A reverse lookup `bot_fp:<fingerprint>` -> `<token>` is also maintained.
|
||||
- Aliases are stored in the `aliases` sled tree (`a:<alias>` -> fingerprint,
|
||||
`fp:<fingerprint>` -> alias).
|
||||
- Queued messages live in the `messages` sled tree under prefix
|
||||
`queue:<bot_fingerprint>:*` and are deleted after `getUpdates` consumes them.
|
||||
- Messages are delivered via `deliver_or_queue` -- live WebSocket if online,
|
||||
otherwise queued.
|
||||
|
||||
---
|
||||
|
||||
## Bot Bridge (`tools/bot-bridge.py`)
|
||||
|
||||
A compatibility layer for existing Telegram bot libraries. Translates between
|
||||
featherChat Bot API and standard TG libraries (python-telegram-bot, aiogram,
|
||||
Telegraf). Handles differences like fingerprint-based chat_id, numeric ID
|
||||
translation, and webhook forwarding.
|
||||
|
||||
```bash
|
||||
python tools/bot-bridge.py --token YOUR_BOT_TOKEN --server http://localhost:7700
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Plans
|
||||
|
||||
- **File send/receive APIs** -- `sendDocument`, `getFile`.
|
||||
- **Group bot support** -- bots in group chats with sender-key encryption.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Warzone Client -- Operation Guide
|
||||
|
||||
**Version:** 0.0.21
|
||||
|
||||
---
|
||||
|
||||
## 1. Installation
|
||||
@@ -21,313 +23,509 @@ The binary is at `target/release/warzone`. You can copy it anywhere or add
|
||||
cargo install --path crates/warzone-client
|
||||
```
|
||||
|
||||
---
|
||||
### Build the WASM Module (Web Client)
|
||||
|
||||
## 2. Quick Start
|
||||
Requires wasm-pack.
|
||||
|
||||
```bash
|
||||
# 1. Generate a new identity
|
||||
warzone init
|
||||
|
||||
# 2. Register your key bundle with a server
|
||||
warzone register -s http://wz.example.com:7700
|
||||
|
||||
# 3. Send an encrypted message
|
||||
warzone send a3f8:c912:44be:7d01 "Hello from Warzone" -s http://wz.example.com:7700
|
||||
|
||||
# 4. Poll for incoming messages
|
||||
warzone recv -s http://wz.example.com:7700
|
||||
cd crates/warzone-wasm
|
||||
wasm-pack build --target web
|
||||
# Output in pkg/ — copy to web client directory
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. CLI Commands
|
||||
## 2. TUI Architecture
|
||||
|
||||
### warzone init
|
||||
The interactive client is built on **ratatui** (rendering) and **crossterm**
|
||||
(terminal I/O). The event loop polls at **100 ms** intervals, giving a
|
||||
responsive feel without busy-waiting.
|
||||
|
||||
Generate a new identity (seed, keypair, and pre-keys).
|
||||
### Module Layout
|
||||
|
||||
The TUI lives in `crates/warzone-client/src/tui/` and is split into seven
|
||||
modules:
|
||||
|
||||
| Module | Responsibility |
|
||||
|-----------------|---------------------------------------------------------|
|
||||
| `types` | Core data structures: `App`, `ChatLine`, `ReceiptStatus`, `PendingFileTransfer`, constants (`MAX_FILE_SIZE`, `CHUNK_SIZE`) |
|
||||
| `draw` | Rendering: header bar, message list with timestamps and receipt indicators, input box with unread badge, scroll windowing |
|
||||
| `commands` | All `/`-prefixed command handlers (peer, alias, group, file, history, friends, devices, etc.) and message send logic |
|
||||
| `input` | Key event dispatch: text editing, cursor movement, scroll, quit |
|
||||
| `file_transfer` | Chunked file send: reads file, SHA-256 hash, splits into 64 KB encrypted chunks |
|
||||
| `network` | WebSocket receive loop (with HTTP polling fallback), incoming message decryption, receipt handling, session auto-recovery |
|
||||
| `mod` | Public entry point `run_tui()`: sets up terminal, spawns network task, runs the 100 ms event loop |
|
||||
|
||||
### Event Loop
|
||||
|
||||
```
|
||||
loop {
|
||||
terminal.draw(app) // ratatui render pass
|
||||
if event::poll(100ms) { // crossterm poll
|
||||
handle key event // Enter → send; everything else → input.rs
|
||||
}
|
||||
if app.should_quit { break }
|
||||
}
|
||||
```
|
||||
|
||||
Messages arrive asynchronously on a background tokio task (`network::poll_loop`)
|
||||
and are pushed into a shared `Arc<Mutex<Vec<ChatLine>>>`.
|
||||
|
||||
---
|
||||
|
||||
## 3. CLI Subcommands
|
||||
|
||||
### `warzone init`
|
||||
|
||||
Generate a new identity (seed, keypair, pre-keys).
|
||||
|
||||
```bash
|
||||
$ warzone init
|
||||
Identity generated!
|
||||
Set passphrase (empty for no encryption): ****
|
||||
Confirm passphrase: ****
|
||||
|
||||
Fingerprint: b7d1:e845:0022:9f3a
|
||||
Your identity:
|
||||
Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
Mnemonic: abandon ability able about above absent absorb abstract ...
|
||||
|
||||
Recovery mnemonic (WRITE THIS DOWN):
|
||||
|
||||
1. abandon 2. ability 3. able 4. about
|
||||
5. above 6. absent 7. absorb 8. abstract
|
||||
9. absurd 10. abuse 11. access 12. accident
|
||||
13. account 14. accuse 15. achieve 16. acid
|
||||
17. acoustic 18. acquire 19. across 20. act
|
||||
21. action 22. actor 23. actress 24. actual
|
||||
|
||||
Seed saved to ~/.warzone/identity.seed
|
||||
Generated 1 signed pre-key + 10 one-time pre-keys
|
||||
|
||||
To register with a server, run:
|
||||
warzone send <recipient-fingerprint> <message> -s http://server:7700
|
||||
|
||||
Or register your key bundle manually:
|
||||
(bundle auto-registered on first send)
|
||||
SAVE YOUR MNEMONIC — it is the ONLY way to recover your identity.
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. Generates 32 random bytes (seed) from `OsRng`.
|
||||
2. Derives Ed25519 signing key and X25519 encryption key from the seed.
|
||||
3. Converts seed to a 24-word BIP39 mnemonic and displays it.
|
||||
4. Saves the raw seed to `~/.warzone/identity.seed` (mode 0600 on Unix).
|
||||
4. Prompts for a passphrase. Encrypts the seed with Argon2id + ChaCha20-Poly1305
|
||||
and saves to `~/.warzone/identity.seed` (mode 0600 on Unix). An empty
|
||||
passphrase stores the seed in plaintext.
|
||||
5. Generates 1 signed pre-key (id=1) and 10 one-time pre-keys (ids 0-9).
|
||||
6. Stores pre-key secrets in the local sled database at `~/.warzone/db/`.
|
||||
7. Saves the public pre-key bundle to `~/.warzone/bundle.bin`.
|
||||
|
||||
---
|
||||
|
||||
### warzone recover \<words...\>
|
||||
### `warzone recover <words...>`
|
||||
|
||||
Recover an identity from a BIP39 mnemonic.
|
||||
Recover an identity from a 24-word BIP39 mnemonic.
|
||||
|
||||
```bash
|
||||
$ warzone recover abandon ability able about above absent absorb abstract \
|
||||
absurd abuse access accident account accuse achieve acid \
|
||||
acoustic acquire across act action actor actress actual
|
||||
Identity recovered!
|
||||
Fingerprint: b7d1:e845:0022:9f3a
|
||||
Seed saved to ~/.warzone/identity.seed
|
||||
Set passphrase (empty for no encryption): ****
|
||||
Confirm passphrase: ****
|
||||
Identity recovered. Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
```
|
||||
|
||||
**Note:** recovery restores the seed and keypair but does NOT restore
|
||||
pre-keys or sessions. You will need to run `warzone init`-style pre-key
|
||||
generation separately or your contacts will need to re-establish sessions.
|
||||
Recovery restores the seed and keypair. Pre-keys and sessions are NOT restored;
|
||||
contacts will need to re-establish sessions.
|
||||
|
||||
---
|
||||
|
||||
### warzone info
|
||||
### `warzone info`
|
||||
|
||||
Display your fingerprint and public keys.
|
||||
|
||||
```bash
|
||||
$ warzone info
|
||||
Fingerprint: b7d1:e845:0022:9f3a
|
||||
Signing key: 3a7c... (64 hex chars)
|
||||
Encryption key: 9d2f... (64 hex chars)
|
||||
Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
Signing key: 3a7b... (64 hex chars)
|
||||
Encryption key: 9f2c... (64 hex chars)
|
||||
```
|
||||
|
||||
Requires a saved identity (`~/.warzone/identity.seed`).
|
||||
|
||||
---
|
||||
|
||||
### warzone register
|
||||
### `warzone tui` / `warzone chat [peer]`
|
||||
|
||||
Register your pre-key bundle with a server.
|
||||
Launch the interactive TUI client.
|
||||
|
||||
```bash
|
||||
$ warzone register -s http://wz.example.com:7700
|
||||
Bundle registered with http://wz.example.com:7700
|
||||
$ warzone chat --server http://wz.example.com:7700
|
||||
$ warzone chat a3f8:c912:44be:7d01:... --server http://wz.example.com:7700
|
||||
$ warzone chat @alice --server http://wz.example.com:7700
|
||||
```
|
||||
|
||||
An optional `peer` argument (fingerprint or `@alias`) pre-sets the active
|
||||
DM target.
|
||||
|
||||
**Flags:**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|------|-------|---------|-------------|
|
||||
|------------|-------|-----------------------|--------------|
|
||||
| `--server` | `-s` | `http://localhost:7700` | Server URL |
|
||||
|
||||
This uploads `~/.warzone/bundle.bin` to the server. Registration is also
|
||||
performed automatically on the first `send`.
|
||||
|
||||
---
|
||||
|
||||
### warzone send
|
||||
### `warzone send <recipient> <message>`
|
||||
|
||||
Send an encrypted message to a recipient.
|
||||
Send an encrypted message. Recipient can be a fingerprint or `@alias`.
|
||||
|
||||
```bash
|
||||
$ warzone send a3f8:c912:44be:7d01 "Hello, are you safe?" -s http://wz.example.com:7700
|
||||
No existing session. Fetching key bundle for a3f8:c912:44be:7d01...
|
||||
Bundle registered with http://wz.example.com:7700
|
||||
Message sent to a3f8:c912:44be:7d01
|
||||
$ warzone send a3f8:c912:44be:7d01:... "Hello!" --server http://wz.example.com:7700
|
||||
$ warzone send @alice "Hello!" --server http://wz.example.com:7700
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `recipient` | Recipient fingerprint (e.g. `a3f8:c912:44be:7d01`) |
|
||||
| `message` | Message text (quote if it contains spaces) |
|
||||
|
||||
**Flags:**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|------|-------|---------|-------------|
|
||||
| `--server` | `-s` | `http://localhost:7700` | Server URL |
|
||||
|
||||
**Behavior:**
|
||||
1. Auto-registers your bundle with the server (if not already done).
|
||||
|
||||
1. Auto-registers your bundle with the server if needed.
|
||||
2. Checks for an existing Double Ratchet session with the recipient.
|
||||
3. If no session exists:
|
||||
- Fetches recipient's pre-key bundle from the server.
|
||||
- Verifies the signed pre-key signature.
|
||||
- Performs X3DH key exchange.
|
||||
- Initializes the Double Ratchet as Alice (initiator).
|
||||
- Sends a `WireMessage::KeyExchange` containing the X3DH parameters
|
||||
and the first encrypted message.
|
||||
4. If a session exists:
|
||||
- Encrypts using the existing ratchet.
|
||||
- Sends a `WireMessage::Message`.
|
||||
3. If no session: fetches the recipient's pre-key bundle, verifies the signed
|
||||
pre-key signature, performs X3DH, initializes the ratchet as Alice, and
|
||||
sends a `WireMessage::KeyExchange` containing the X3DH parameters and the
|
||||
first encrypted message.
|
||||
4. If a session exists: encrypts with the existing ratchet and sends a
|
||||
`WireMessage::Message`.
|
||||
5. Updates the local session state.
|
||||
|
||||
---
|
||||
|
||||
### warzone recv
|
||||
### `warzone recv`
|
||||
|
||||
Poll for and decrypt incoming messages.
|
||||
|
||||
```bash
|
||||
$ warzone recv -s http://wz.example.com:7700
|
||||
Polling for messages as b7d1:e845:0022:9f3a...
|
||||
Received 2 message(s):
|
||||
|
||||
[new session] a3f8:c912:44be:7d01: Hello, are you safe?
|
||||
a3f8:c912:44be:7d01: I'm sending supplies tomorrow.
|
||||
$ warzone recv --server http://wz.example.com:7700
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|------|-------|---------|-------------|
|
||||
| `--server` | `-s` | `http://localhost:7700` | Server URL |
|
||||
|
||||
**Behavior:**
|
||||
1. Polls `/v1/messages/poll/{our_fingerprint}`.
|
||||
2. For each message:
|
||||
- Deserializes the `WireMessage` from bincode.
|
||||
- **KeyExchange:** loads signed pre-key secret and (if applicable)
|
||||
one-time pre-key secret from local storage, performs X3DH respond,
|
||||
initializes ratchet as Bob, decrypts the message, and saves the session.
|
||||
- **Message:** loads existing session, decrypts with the ratchet, saves
|
||||
updated session state.
|
||||
3. Prints decrypted messages to stdout.
|
||||
|
||||
**Note:** messages are currently NOT acknowledged after polling. They will
|
||||
be returned again on the next poll. Acknowledgment is TODO.
|
||||
Fetches messages from `/v1/messages/poll/{fingerprint}`, deserializes each
|
||||
`WireMessage`, performs X3DH respond or ratchet decrypt as appropriate, and
|
||||
prints plaintext to stdout.
|
||||
|
||||
---
|
||||
|
||||
### warzone chat
|
||||
### `warzone backup [output]`
|
||||
|
||||
Launch the interactive TUI.
|
||||
Export an encrypted backup of local data (sessions, pre-keys).
|
||||
|
||||
```bash
|
||||
$ warzone chat -s http://wz.example.com:7700
|
||||
TODO: launch TUI connected to http://wz.example.com:7700
|
||||
$ warzone backup my-backup.wzb
|
||||
Backup saved to my-backup.wzb (4096 bytes encrypted)
|
||||
```
|
||||
|
||||
**Status:** not yet implemented. The TUI will use `ratatui` and `crossterm`
|
||||
(dependencies are already in `Cargo.toml`). Planned for Phase 2.
|
||||
The backup is encrypted with `HKDF(seed, info="warzone-history")` +
|
||||
ChaCha20-Poly1305.
|
||||
|
||||
**Backup file format:**
|
||||
|
||||
```
|
||||
WZH1 (4 bytes) + nonce (12) + ciphertext
|
||||
|
||||
Plaintext: JSON {
|
||||
"version": 1,
|
||||
"sessions": { "<fp>": "base64_bincode", ... },
|
||||
"pre_keys": { "spk:1": "base64_bytes", "otpk:1": "base64_bytes", ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Identity Management
|
||||
### `warzone restore <input>`
|
||||
|
||||
### Storage Layout
|
||||
|
||||
```
|
||||
~/.warzone/
|
||||
identity.seed # 32-byte raw seed (plaintext -- encryption is TODO)
|
||||
bundle.bin # bincode-serialized PreKeyBundle (public data)
|
||||
db/ # sled database directory
|
||||
sessions/ # Double Ratchet state per peer
|
||||
pre_keys/ # signed and one-time pre-key secrets
|
||||
```
|
||||
|
||||
### File Permissions
|
||||
|
||||
On Unix, `identity.seed` is created with mode `0600` (owner read/write only).
|
||||
The sled database directory inherits default permissions.
|
||||
|
||||
### Seed Security
|
||||
|
||||
**Current state:** the seed is stored as **plaintext** 32 bytes. This is a
|
||||
known Phase 1 limitation.
|
||||
|
||||
**Planned (Phase 2):** encrypt the seed at rest using:
|
||||
- Passphrase input at startup
|
||||
- Argon2id key derivation from passphrase
|
||||
- ChaCha20-Poly1305 encryption of the seed bytes
|
||||
|
||||
### Mnemonic Backup
|
||||
|
||||
The 24-word BIP39 mnemonic shown during `init` is the ONLY way to recover
|
||||
your identity if you lose `~/.warzone/`. Write it down on paper and store it
|
||||
securely.
|
||||
|
||||
The mnemonic is displayed once at generation time and can be recovered from
|
||||
the seed using the protocol library, but the CLI does not currently expose a
|
||||
"show mnemonic" command.
|
||||
|
||||
### Recovery
|
||||
Restore from an encrypted backup. Requires the same seed (passphrase prompt).
|
||||
|
||||
```bash
|
||||
warzone recover word1 word2 word3 ... word24
|
||||
$ warzone restore my-backup.wzb
|
||||
Restored 12 entries from my-backup.wzb
|
||||
```
|
||||
|
||||
This recreates `~/.warzone/identity.seed` with the same seed. The same
|
||||
fingerprint and keypairs are derived deterministically. However:
|
||||
|
||||
- Pre-keys are NOT regenerated. Run `warzone init` on a fresh directory to
|
||||
generate new pre-keys (this will also generate a new seed, so you would need
|
||||
to coordinate).
|
||||
- Sessions are NOT recovered. All contacts will need to establish new sessions.
|
||||
|
||||
**TODO:** a `recover` flow that also regenerates pre-keys without creating a
|
||||
new seed.
|
||||
Merges data without overwriting existing entries.
|
||||
|
||||
---
|
||||
|
||||
## 5. Web Client
|
||||
## 4. TUI Features
|
||||
|
||||
The web client is served by the server at `/`. Open it in a browser:
|
||||
### Message Timestamps
|
||||
|
||||
```
|
||||
http://localhost:7700/
|
||||
```
|
||||
Every message is rendered with a `[HH:MM]` prefix in dark gray, derived from
|
||||
`chrono::Local::now()` at receive/send time.
|
||||
|
||||
### Features
|
||||
### Message Scrolling
|
||||
|
||||
- **Generate New Identity:** creates a random 32-byte seed in the browser.
|
||||
- **Recover from Mnemonic:** paste a hex-encoded seed (not BIP39 words;
|
||||
hex encoding is used as a placeholder).
|
||||
- **Chat interface:** dark-themed monospace UI with message display.
|
||||
- **Commands:**
|
||||
- `/help` -- show available commands
|
||||
- `/info` -- show your fingerprint
|
||||
- `/seed` -- display your seed (hex-encoded)
|
||||
The message area supports scrolling with a "pinned to bottom" model:
|
||||
|
||||
- `scroll_offset = 0` means the newest messages are visible.
|
||||
- Scrolling up increases the offset; scrolling down decreases it.
|
||||
- The visible window is computed as `items[total - offset - height .. total - offset]`.
|
||||
|
||||
### Connection Status Indicator
|
||||
|
||||
The header bar displays a colored dot after the server URL:
|
||||
|
||||
- Green dot: WebSocket connection active.
|
||||
- Red dot: disconnected (HTTP polling fallback or reconnecting).
|
||||
|
||||
### Unread Badge
|
||||
|
||||
When `scroll_offset > 0`, the input box title changes from `" message "` to
|
||||
`" [N new] "` showing how many messages are below the current scroll position.
|
||||
This makes it obvious that new content has arrived while reading history.
|
||||
|
||||
### Terminal Bell
|
||||
|
||||
A terminal bell (`\x07`) is emitted on every incoming DM (both `KeyExchange`
|
||||
and `Message` wire types). This triggers a system notification in most terminal
|
||||
emulators.
|
||||
|
||||
### Receipt Indicators
|
||||
|
||||
Sent messages display delivery status after the message text:
|
||||
|
||||
| Indicator | Meaning |
|
||||
|-----------------|----------------------------------------|
|
||||
| Single tick | Sent (no confirmation yet) |
|
||||
| Double tick | Delivered (decrypted by recipient) |
|
||||
| Double tick blue| Read (viewed by recipient) |
|
||||
|
||||
### Session Auto-Recovery
|
||||
|
||||
When decryption fails on an incoming message, the TUI automatically:
|
||||
|
||||
1. Deletes the corrupted session from the local database.
|
||||
2. Displays a system message: `[session reset] Decryption failed for <fp>. Session cleared -- next message will re-establish.`
|
||||
|
||||
The next incoming `KeyExchange` from that peer will create a fresh session
|
||||
without manual intervention.
|
||||
|
||||
---
|
||||
|
||||
## 5. Full Command Reference
|
||||
|
||||
All commands start with `/` and are entered in the TUI input box.
|
||||
|
||||
### Peer and Navigation
|
||||
|
||||
| Command | Short | Description |
|
||||
|------------------------|---------|----------------------------------------------|
|
||||
| `/peer <fp_or_alias>` | `/p` | Set the active DM peer (fingerprint or @alias) |
|
||||
| `/dm` | | Switch to DM mode (clear group context) |
|
||||
| `/reply` | `/r` | Switch to the last person who DM'd you |
|
||||
| `/info` | | Display your fingerprint |
|
||||
| `/eth` | | Display your Ethereum address (derived from seed) |
|
||||
| `/seed` | | Display your 24-word recovery mnemonic |
|
||||
| `/quit` | `/q` | Exit the TUI |
|
||||
| `/help` | `/?` | Show the built-in help text |
|
||||
|
||||
### Alias Management
|
||||
|
||||
| Command | Description |
|
||||
|-----------------------|--------------------------------------------|
|
||||
| `/alias <name>` | Register an alias for your fingerprint. Returns a recovery key -- save it. |
|
||||
| `/unalias` | Remove your alias from the server |
|
||||
| `/aliases` | List all registered aliases on the server |
|
||||
|
||||
Alias rules: 1-32 alphanumeric characters (plus `_` and `-`), case-insensitive,
|
||||
normalized to lowercase. TTL is 365 days of inactivity with a 30-day grace
|
||||
period before reclamation.
|
||||
|
||||
### Contacts and History
|
||||
|
||||
| Command | Short | Description |
|
||||
|------------------------|---------|------------------------------------------|
|
||||
| `/contacts` | `/c` | List all contacts with message counts |
|
||||
| `/history [peer]` | `/h` | Show message history (last 50 messages). Uses current peer if set. |
|
||||
|
||||
### Group Commands
|
||||
|
||||
| Command | Description |
|
||||
|-------------------------|------------------------------------------|
|
||||
| `/g <name>` | Switch to group (auto-join if needed) |
|
||||
| `/gcreate <name>` | Create a new group (you become creator) |
|
||||
| `/gjoin <name>` | Join an existing group |
|
||||
| `/gleave` | Leave the current group |
|
||||
| `/gkick <fp_or_alias>` | Kick a member (creator only) |
|
||||
| `/gmembers` | List members of the current group |
|
||||
| `/glist` | List all groups on the server |
|
||||
|
||||
Group messages use Sender Keys for O(1) encryption per message. Each member
|
||||
generates a `SenderKey` distributed via 1:1 encrypted channels. Keys rotate on
|
||||
member join/leave.
|
||||
|
||||
### File Transfer
|
||||
|
||||
| Command | Description |
|
||||
|-------------------|----------------------------------------------|
|
||||
| `/file <path>` | Send a file to the current peer or group |
|
||||
|
||||
Constraints:
|
||||
|
||||
- Maximum file size: 10 MB
|
||||
- Chunk size: 64 KB
|
||||
- Files are sent as `FileHeader` + encrypted `FileChunk` wire messages
|
||||
- SHA-256 verification on receipt
|
||||
- Received files are saved to `~/.warzone/downloads/`
|
||||
|
||||
### Device Management
|
||||
|
||||
| Command | Description |
|
||||
|-----------------------|------------------------------------------|
|
||||
| `/devices` | List your active device sessions |
|
||||
| `/kick <device_id>` | Kick a specific device session |
|
||||
|
||||
---
|
||||
|
||||
## 6. Keyboard Shortcuts
|
||||
|
||||
### Text Editing
|
||||
|
||||
| Key | Action |
|
||||
|------------------|---------------------------------|
|
||||
| Left / Right | Move cursor one character |
|
||||
| Home / Ctrl+A | Move to beginning of line |
|
||||
| End / Ctrl+E | Move to end of line |
|
||||
| Backspace | Delete character before cursor |
|
||||
| Delete | Delete character at cursor |
|
||||
| Ctrl+U | Clear entire input line |
|
||||
| Ctrl+K | Kill from cursor to end of line |
|
||||
| Ctrl+W | Delete word before cursor |
|
||||
| Alt+Backspace | Delete word before cursor |
|
||||
| Alt+Left | Jump one word left |
|
||||
| Alt+Right | Jump one word right |
|
||||
|
||||
### Scrolling
|
||||
|
||||
| Key | Action |
|
||||
|------------------|------------------------------------------|
|
||||
| PageUp | Scroll up 10 messages |
|
||||
| PageDown | Scroll down 10 messages |
|
||||
| Up | Scroll up 1 message (when input is empty)|
|
||||
| Down | Scroll down 1 message (when input is empty)|
|
||||
| End | Snap to bottom (when input is empty) |
|
||||
| Ctrl+End | Snap to bottom (always) |
|
||||
|
||||
### Quit
|
||||
|
||||
| Key | Action |
|
||||
|------------------|---------|
|
||||
| Ctrl+C | Quit |
|
||||
| Esc | Quit |
|
||||
|
||||
---
|
||||
|
||||
## 7. Friend List
|
||||
|
||||
The friend list is an E2E encrypted contact list stored on the server as an
|
||||
opaque blob. The server never sees the plaintext.
|
||||
|
||||
### Encryption
|
||||
|
||||
- Key derivation: `HKDF(seed, info="warzone-friends")` produces a 32-byte key.
|
||||
- Encryption: ChaCha20-Poly1305 with AAD `"warzone-friends-aad"`.
|
||||
- Plaintext format: JSON-serialized `FriendList` containing address, alias,
|
||||
and `added_at` timestamp per friend.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
|------------------------|------------------------------------------------|
|
||||
| `/friend` | List all friends with online/offline presence |
|
||||
| `/friend <address>` | Add a friend (fingerprint or ETH address) |
|
||||
| `/unfriend <address>` | Remove a friend |
|
||||
|
||||
When listing friends, the TUI queries the server's presence endpoint for each
|
||||
friend to show real-time online/offline status.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Seed is generated with `crypto.getRandomValues(32)`.
|
||||
2. ECDH P-256 keypair is derived (not X25519 -- Web Crypto limitation).
|
||||
3. Fingerprint is `SHA-256(ECDH_public_key)[0..16]` formatted as 4 hex
|
||||
groups.
|
||||
4. Seed is saved in `localStorage` under key `wz-seed`.
|
||||
5. On page load, the client tries to auto-load a saved seed.
|
||||
6. Public key is registered with the server via `POST /v1/keys/register`.
|
||||
7. Messages are polled every 5 seconds from `/v1/messages/poll/{fingerprint}`.
|
||||
1. On `/friend <address>`: the client fetches the current encrypted blob from
|
||||
the server, decrypts it, adds the entry, re-encrypts, and uploads.
|
||||
2. On `/unfriend <address>`: same fetch-decrypt-modify-encrypt-upload cycle.
|
||||
3. On `/friend` (no argument): fetches and decrypts the blob, then checks
|
||||
`/v1/presence/<fp>` for each friend.
|
||||
|
||||
### Limitations
|
||||
|
||||
- **No cross-client compatibility:** the web client uses P-256 while the CLI
|
||||
uses X25519/Ed25519. Messages between the two cannot be decrypted. This
|
||||
will be resolved in Phase 2 (WASM port of the protocol library).
|
||||
- **No Double Ratchet:** message decryption is not implemented in JS.
|
||||
Received messages display as `[encrypted message]`.
|
||||
- **No BIP39:** seed is shown as hex bytes, not mnemonic words.
|
||||
- **Unencrypted seed storage:** `localStorage` is accessible to any JS on
|
||||
the same origin.
|
||||
The server stores the blob at `POST /v1/friends` and returns it at
|
||||
`GET /v1/friends`. It has no knowledge of the contents.
|
||||
|
||||
---
|
||||
|
||||
## 6. Session Management
|
||||
## 8. Local Storage
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```
|
||||
~/.warzone/
|
||||
identity.seed # Encrypted seed (Argon2id + ChaCha20-Poly1305)
|
||||
bundle.bin # bincode-serialized PreKeyBundle (public data)
|
||||
db/ # sled database directory
|
||||
sessions/ # Double Ratchet state per peer (keyed by hex fingerprint)
|
||||
pre_keys/ # Signed and one-time pre-key secrets
|
||||
contacts/ # Contact metadata and message counts
|
||||
history/ # Message history per peer
|
||||
sender_keys/ # Sender Key state for group encryption
|
||||
downloads/ # Received files from /file transfers
|
||||
```
|
||||
|
||||
### Seed Encryption
|
||||
|
||||
The seed file uses a fixed format:
|
||||
|
||||
```
|
||||
WZS1 (4 bytes magic) + salt (16) + nonce (12) + ciphertext (48)
|
||||
|
||||
Encryption: Argon2id(passphrase, salt) -> 32-byte key
|
||||
ChaCha20-Poly1305(key, nonce, seed) -> ciphertext
|
||||
```
|
||||
|
||||
An empty passphrase at `init` time stores the seed in plaintext (for testing
|
||||
only). The seed file is created with mode `0600` (owner read/write) on Unix.
|
||||
|
||||
### Mnemonic Backup
|
||||
|
||||
The 24-word BIP39 mnemonic shown during `init` is the only way to recover
|
||||
your identity if you lose `~/.warzone/`. Write it down on paper. You can also
|
||||
view it later with `/seed` in the TUI.
|
||||
|
||||
---
|
||||
|
||||
## 9. Web Client
|
||||
|
||||
The web client is served by the server at `/` and uses a **WASM bridge**
|
||||
(`warzone-wasm`) that exposes the exact same cryptographic primitives as the
|
||||
CLI: X25519, ChaCha20-Poly1305, X3DH, Double Ratchet.
|
||||
|
||||
### Features
|
||||
|
||||
- **Same crypto as TUI:** the WASM module wraps `warzone-protocol` directly,
|
||||
so web-to-CLI interoperability is fully supported.
|
||||
- **URL deep links:** paths like `/message/@alias`, `/message/0xABC`, and
|
||||
`/group/#ops` auto-navigate to the corresponding conversation.
|
||||
- **Clickable addresses:** fingerprints and aliases in the chat are rendered
|
||||
as interactive links.
|
||||
- **Service worker cache:** all shell assets (`/`, WASM JS, WASM binary,
|
||||
manifest, icon) are cached by a versioned service worker (`wz-v2`). The
|
||||
cache name is bumped on updates to force refresh.
|
||||
- **PWA support:** includes a manifest and install prompt (`/install` command).
|
||||
- **BIP39 mnemonic:** seed is displayed as 24 words via the WASM bridge
|
||||
(not hex).
|
||||
|
||||
### Web-Only Commands
|
||||
|
||||
| Command | Description |
|
||||
|-------------------|----------------------------------------------------|
|
||||
| `/selftest` | Run WASM crypto self-test (X3DH + ratchet cycle) |
|
||||
| `/bundleinfo` | Debug: show bundle details (keys, sizes) |
|
||||
| `/debug` | Toggle debug mode (verbose output) |
|
||||
| `/reset` | Clear identity and all local data |
|
||||
| `/install` | Show PWA installation instructions |
|
||||
| `/sessions` | List active ratchet sessions |
|
||||
| `/admin-unalias` | Admin: remove any alias (requires admin password) |
|
||||
|
||||
### Web Client Storage
|
||||
|
||||
Data is stored in `localStorage`:
|
||||
|
||||
| Key | Value | Purpose |
|
||||
|----------------------|--------------------------------|----------------------------|
|
||||
| `wz_seed` | hex seed (64 chars) | Identity seed |
|
||||
| `wz_spk_secret` | hex SPK secret (64 chars) | Signed pre-key secret |
|
||||
| `wz_session:<fp>` | base64 ratchet state | Per-peer session |
|
||||
| `wz_contacts` | JSON contact list | Contact metadata |
|
||||
|
||||
---
|
||||
|
||||
## 10. Session Management
|
||||
|
||||
### How Sessions Work
|
||||
|
||||
@@ -336,172 +534,63 @@ by their fingerprint.
|
||||
|
||||
1. **First message to a peer:** X3DH key exchange establishes a shared secret.
|
||||
The ratchet is initialized. The session is saved in `~/.warzone/db/`
|
||||
under the `sessions` tree, keyed by the peer's fingerprint (hex-encoded).
|
||||
under the `sessions` tree, keyed by the peer's hex fingerprint.
|
||||
|
||||
2. **Subsequent messages:** the ratchet state is loaded, used to encrypt or
|
||||
decrypt, then saved back.
|
||||
|
||||
3. **Bidirectional:** both parties maintain the same session. When Bob
|
||||
receives Alice's KeyExchange, he initializes his side of the ratchet. From
|
||||
then on, both use `WireMessage::Message`.
|
||||
3. **Bidirectional:** when Bob receives Alice's `KeyExchange`, he initializes
|
||||
his side. From then on, both use `WireMessage::Message`.
|
||||
|
||||
### Session Storage
|
||||
### Session Auto-Recovery
|
||||
|
||||
Sessions are serialized with `bincode` and stored in the `sessions` sled
|
||||
tree. The key is the peer's 32-character hex fingerprint.
|
||||
On decrypt failure, the TUI deletes the corrupted session and displays a
|
||||
warning. The next incoming `KeyExchange` from that peer re-establishes the
|
||||
session automatically. No manual intervention required.
|
||||
|
||||
### Session Reset
|
||||
### Multi-Device
|
||||
|
||||
There is currently no command to reset a session. If a session becomes
|
||||
corrupted or out of sync:
|
||||
|
||||
1. Delete the local database: `rm -rf ~/.warzone/db/`
|
||||
2. Re-run `warzone init` to generate new pre-keys.
|
||||
3. Re-register with the server.
|
||||
4. Your contact must also reset their session with you.
|
||||
|
||||
**TODO (Phase 2):** a `warzone reset-session <fingerprint>` command.
|
||||
The server stores per-device bundles (`device:<fp>:<device_id>`). Multiple
|
||||
WebSocket connections per fingerprint are supported -- all connected devices
|
||||
receive messages. Ratchet sessions are per-device and not synchronized; use
|
||||
`warzone backup` / `warzone restore` to transfer session state.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pre-Key Management
|
||||
|
||||
### What Are Pre-Keys
|
||||
|
||||
Pre-keys enable asynchronous session establishment. When Alice wants to
|
||||
message Bob for the first time:
|
||||
|
||||
1. Alice fetches Bob's **pre-key bundle** from the server.
|
||||
2. The bundle contains Bob's public identity key, a signed pre-key, and
|
||||
optionally a one-time pre-key.
|
||||
3. Alice uses these to perform X3DH without Bob being online.
|
||||
|
||||
### Pre-Key Types
|
||||
|
||||
| Type | Quantity | Lifetime | Purpose |
|
||||
|------|----------|----------|---------|
|
||||
| Signed pre-key | 1 (id=1) | Long-term (no rotation yet) | Medium-term DH key, signed by identity |
|
||||
| One-time pre-keys | 10 (ids 0-9) | Single use | Consumed during X3DH, then deleted |
|
||||
|
||||
### When to Replenish
|
||||
|
||||
One-time pre-keys are consumed when someone initiates a session with you.
|
||||
After all 10 are used, X3DH falls back to using only the signed pre-key
|
||||
(DH4 is skipped), which provides slightly weaker security properties.
|
||||
|
||||
**Current state:** there is no automatic replenishment. You must manually
|
||||
re-initialize if you expect many incoming new sessions.
|
||||
|
||||
**TODO (Phase 2):** the server will notify the client when one-time pre-key
|
||||
supply is low, and the client will upload fresh ones automatically.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Model
|
||||
|
||||
### What Is Encrypted
|
||||
|
||||
- **Message body:** encrypted with ChaCha20-Poly1305 using per-message keys
|
||||
from the Double Ratchet. Even the server cannot read it.
|
||||
|
||||
### What Is NOT Encrypted
|
||||
|
||||
- **Sender fingerprint:** visible to the server and anyone intercepting
|
||||
traffic.
|
||||
- **Recipient fingerprint:** visible to the server (needed for routing).
|
||||
- **Message size:** visible to the server.
|
||||
- **Timing:** when messages are sent and received.
|
||||
- **IP addresses:** visible to the server and network observers.
|
||||
- **Seed on disk:** stored as plaintext (encryption TODO).
|
||||
|
||||
### Threat Model
|
||||
|
||||
| Threat | Protected? | Notes |
|
||||
|--------|-----------|-------|
|
||||
| Server reads messages | Yes | E2E encryption; server sees only ciphertext |
|
||||
| Network eavesdropper reads messages | Yes | E2E encryption |
|
||||
| Server impersonates a user | Partially | Pre-key signatures prevent forgery of signed pre-keys, but the server could substitute a fake bundle (no key transparency yet) |
|
||||
| Compromised past session key | Yes | Forward secrecy via chain ratchet; break-in recovery via DH ratchet |
|
||||
| Stolen device (seed file) | No | Seed is plaintext on disk (encryption TODO) |
|
||||
| Metadata analysis (who talks to whom) | No | Fingerprints visible to server |
|
||||
| Active MITM on first contact | Partially | TOFU model; no out-of-band verification mechanism in the client yet |
|
||||
| One-time pre-keys exhausted | Graceful degradation | X3DH works without OT pre-keys but with reduced replay protection |
|
||||
|
||||
### Trust Model
|
||||
|
||||
**Trust on first use (TOFU):** the first time you message someone, you trust
|
||||
that the server returns their genuine pre-key bundle. There is no
|
||||
verification step yet.
|
||||
|
||||
**Planned (Phase 3):** DNS-based key transparency where users publish
|
||||
self-signed public keys in DNS TXT records, allowing cross-verification
|
||||
independent of the server.
|
||||
|
||||
---
|
||||
|
||||
## 9. Troubleshooting
|
||||
## 11. Troubleshooting
|
||||
|
||||
### "No identity found. Run `warzone init` first."
|
||||
|
||||
You haven't generated an identity, or `~/.warzone/identity.seed` is missing.
|
||||
|
||||
```bash
|
||||
warzone init
|
||||
```
|
||||
`~/.warzone/identity.seed` is missing. Run `warzone init`.
|
||||
|
||||
### "No bundle found. Run `warzone init` first."
|
||||
|
||||
The pre-key bundle file `~/.warzone/bundle.bin` is missing. This happens if
|
||||
you ran `recover` without a full `init`.
|
||||
|
||||
Re-run `warzone init` (this will generate a NEW identity). To keep your
|
||||
recovered identity, you would need to manually regenerate pre-keys (not yet
|
||||
supported as a standalone command).
|
||||
`~/.warzone/bundle.bin` is missing. This happens if you ran `recover` without
|
||||
regenerating pre-keys. Re-run `warzone init` (generates a new identity).
|
||||
|
||||
### "failed to fetch recipient's bundle. Are they registered?"
|
||||
|
||||
The recipient has not registered their pre-key bundle with the server, or
|
||||
you are using the wrong server URL, or the fingerprint is incorrect.
|
||||
|
||||
- Verify the fingerprint (ask the recipient for theirs via `warzone info`).
|
||||
- Verify the server URL.
|
||||
- Ask the recipient to run `warzone register -s <server>`.
|
||||
The recipient has not registered with the server, or the fingerprint / alias
|
||||
is wrong, or the server URL is incorrect. Verify with `warzone info` and
|
||||
`warzone register`.
|
||||
|
||||
### "X3DH respond failed" / "missing signed pre-key"
|
||||
|
||||
Your signed pre-key secret is missing from the local database. This can
|
||||
happen if:
|
||||
- The database was deleted or corrupted.
|
||||
- You recovered an identity but did not regenerate pre-keys.
|
||||
Signed pre-key secret missing from local database. Database may have been
|
||||
deleted or corrupted. Re-initialize with `warzone init`.
|
||||
|
||||
Fix: re-initialize with `warzone init` (generates a new identity) or restore
|
||||
from backup.
|
||||
### "[session reset] Decryption failed"
|
||||
|
||||
### "decrypt failed" / "no session"
|
||||
|
||||
- **"no session"**: you received a `WireMessage::Message` from someone you
|
||||
have no session with. This means you missed their initial `KeyExchange`
|
||||
message, or your session database was lost. Ask them to re-send their first
|
||||
message.
|
||||
- **"decrypt failed"**: the ratchet state is out of sync. This can happen if
|
||||
one side's state was lost or if messages were duplicated. Reset the session
|
||||
on both sides.
|
||||
|
||||
### Messages Keep Reappearing on recv
|
||||
|
||||
Messages are not auto-acknowledged after polling. This is a known Phase 1
|
||||
limitation. The same messages will be returned on every `recv` call.
|
||||
|
||||
**Workaround:** none currently. Acknowledgment will be added in Phase 2.
|
||||
The TUI auto-recovery has cleared the corrupted session. Ask the other party
|
||||
to send a new message -- a fresh `KeyExchange` will re-establish the session.
|
||||
|
||||
### Corrupted Database
|
||||
|
||||
If `~/.warzone/db/` is corrupted:
|
||||
|
||||
```bash
|
||||
# Back up your seed first
|
||||
cp ~/.warzone/identity.seed ~/identity.seed.bak
|
||||
rm -rf ~/.warzone/db/
|
||||
warzone init # regenerate pre-keys (NOTE: generates a new identity)
|
||||
# To keep your old identity, recover from mnemonic after:
|
||||
warzone recover <24 words>
|
||||
```
|
||||
|
||||
To keep your existing identity, manually copy `identity.seed` before
|
||||
deleting, then use `warzone recover` after re-init.
|
||||
|
||||
264
warzone/docs/LLM_BOT_DEV.md
Normal file
264
warzone/docs/LLM_BOT_DEV.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# featherChat Bot Development Reference
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Server must run with `--enable-bots`:
|
||||
```bash
|
||||
warzone-server --bind 0.0.0.0:7700 --enable-bots
|
||||
```
|
||||
|
||||
## Creating a Bot
|
||||
|
||||
Message `@botfather` in the chat client (TUI or web):
|
||||
|
||||
```
|
||||
You: /peer @botfather
|
||||
You: /newbot MyAssistantBot
|
||||
BotFather: Done! Your new bot @myassistantbot is ready.
|
||||
Token: a1b2c3d4e5f6a7b8:9876543210abcdef...
|
||||
Keep this token secret!
|
||||
```
|
||||
|
||||
BotFather commands:
|
||||
- `/newbot <name>` — create bot (name must end with bot/Bot)
|
||||
- `/mybots` — list your bots
|
||||
- `/deletebot <name>` — delete bot you own
|
||||
- `/token <name>` — show token for your bot
|
||||
- `/help` — show commands
|
||||
|
||||
## How Users Message Bots
|
||||
|
||||
When a user messages a bot alias (`@*bot`, `@*Bot`, `@*_bot`, `@botfather`), the client **automatically sends plaintext** — no E2E encryption. The bot receives readable `text` in getUpdates.
|
||||
|
||||
This is automatic — no configuration needed. The client detects the bot alias suffix.
|
||||
|
||||
## API Base
|
||||
|
||||
```
|
||||
http://SERVER:7700/v1/bot/TOKEN/METHOD
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
### getMe
|
||||
```
|
||||
GET /v1/bot/TOKEN/getMe
|
||||
→ {"ok":true,"result":{"id":123456,"id_str":"aabbccdd...","is_bot":true,"first_name":"MyBot"}}
|
||||
```
|
||||
|
||||
### getUpdates
|
||||
```
|
||||
POST /v1/bot/TOKEN/getUpdates
|
||||
{"offset":LAST_ID+1,"timeout":50,"limit":100}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{"ok":true,"result":[
|
||||
{"update_id":1,"message":{
|
||||
"message_id":"uuid",
|
||||
"from":{"id":123456,"is_bot":false},
|
||||
"chat":{"id":123456,"type":"private"},
|
||||
"date":1711612800,
|
||||
"text":"Hello bot!"
|
||||
}}
|
||||
]}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
- `offset` — skip updates < offset (acknowledge processed). **Always use this.**
|
||||
- `timeout` — long-poll seconds (max 50, matches Telegram)
|
||||
- `limit` — max updates (default 100)
|
||||
- `from.id` — numeric (per-bot unique hash, different bots see different IDs for same user)
|
||||
- No raw fingerprint exposed to bots (privacy: bots can't correlate users cross-bot)
|
||||
|
||||
### sendMessage
|
||||
```
|
||||
POST /v1/bot/TOKEN/sendMessage
|
||||
{
|
||||
"chat_id": "fingerprint_hex_or_numeric_id",
|
||||
"text": "Hello!",
|
||||
"parse_mode": "HTML",
|
||||
"reply_to_message_id": "msg_uuid",
|
||||
"reply_markup": {
|
||||
"inline_keyboard": [
|
||||
[{"text":"Yes","callback_data":"yes"},{"text":"No","callback_data":"no"}]
|
||||
]
|
||||
}
|
||||
}
|
||||
→ {"ok":true,"result":{"message_id":"uuid","delivered":true}}
|
||||
```
|
||||
|
||||
`chat_id` accepts: hex fingerprint string, numeric i64, or `0x` ETH address.
|
||||
`parse_mode` "HTML" renders `<b>`, `<i>`, `<code>`, `<a>` in web client.
|
||||
|
||||
### editMessageText
|
||||
```
|
||||
POST /v1/bot/TOKEN/editMessageText
|
||||
{"chat_id":"..","message_id":"uuid","text":"Updated","reply_markup":{...}}
|
||||
```
|
||||
|
||||
### answerCallbackQuery
|
||||
```
|
||||
POST /v1/bot/TOKEN/answerCallbackQuery
|
||||
{"callback_query_id":"id","text":"Done!","show_alert":false}
|
||||
→ {"ok":true,"result":true}
|
||||
```
|
||||
|
||||
### sendDocument
|
||||
```
|
||||
POST /v1/bot/TOKEN/sendDocument
|
||||
{"chat_id":"..","document":"filename_or_url","caption":"optional"}
|
||||
```
|
||||
|
||||
### Webhooks
|
||||
```
|
||||
POST /v1/bot/TOKEN/setWebhook {"url":"https://mybot.example.com/hook"}
|
||||
POST /v1/bot/TOKEN/deleteWebhook
|
||||
GET /v1/bot/TOKEN/getWebhookInfo
|
||||
```
|
||||
|
||||
When set, updates are POSTed to the URL instead of queued for getUpdates.
|
||||
|
||||
## Update Types
|
||||
|
||||
**User message (plaintext — default for bot recipients):**
|
||||
```json
|
||||
{"update_id":1,"message":{"message_id":"id","from":{"id":123,"id_str":"fp"},"chat":{"id":123,"id_str":"fp","type":"private"},"text":"Hello bot!","date":1234567890}}
|
||||
```
|
||||
|
||||
**Bot-to-bot message:**
|
||||
```json
|
||||
{"update_id":2,"message":{"message_id":"id","from":{"id":456,"is_bot":true},"chat":{"id":456,"type":"private"},"text":"inter-bot msg","date":1234567890}}
|
||||
```
|
||||
|
||||
**E2E encrypted (user sent without bot detection — rare):**
|
||||
```json
|
||||
{"update_id":3,"message":{"text":null,"raw_encrypted":"base64..."}}
|
||||
```
|
||||
|
||||
**File:**
|
||||
```json
|
||||
{"update_id":4,"message":{"document":{"file_name":"report.pdf","file_size":1234}}}
|
||||
```
|
||||
|
||||
## Python Echo Bot
|
||||
|
||||
```python
|
||||
import requests, time
|
||||
|
||||
TOKEN = "YOUR_TOKEN" # from @botfather /newbot
|
||||
API = f"http://localhost:7700/v1/bot/{TOKEN}"
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
r = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 50}).json()
|
||||
for u in r.get("result", []):
|
||||
offset = u["update_id"] + 1
|
||||
msg = u.get("message", {})
|
||||
text = msg.get("text")
|
||||
chat_id = msg.get("chat", {}).get("id", "")
|
||||
if text and chat_id:
|
||||
requests.post(f"{API}/sendMessage", json={"chat_id": chat_id, "text": f"Echo: {text}"})
|
||||
time.sleep(0.1)
|
||||
```
|
||||
|
||||
## Python Menu Bot (Inline Keyboard)
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
TOKEN = "YOUR_TOKEN"
|
||||
API = f"http://localhost:7700/v1/bot/{TOKEN}"
|
||||
offset = 0
|
||||
|
||||
def menu(chat_id):
|
||||
requests.post(f"{API}/sendMessage", json={
|
||||
"chat_id": chat_id, "text": "Pick one:",
|
||||
"reply_markup": {"inline_keyboard": [
|
||||
[{"text": "A", "callback_data": "a"}, {"text": "B", "callback_data": "b"}]
|
||||
]}
|
||||
})
|
||||
|
||||
while True:
|
||||
r = requests.post(f"{API}/getUpdates", json={"offset": offset, "timeout": 50}).json()
|
||||
for u in r.get("result", []):
|
||||
offset = u["update_id"] + 1
|
||||
msg = u.get("message", {})
|
||||
text, cid = msg.get("text", ""), msg.get("chat", {}).get("id", "")
|
||||
if text == "/start": menu(cid)
|
||||
elif text: requests.post(f"{API}/sendMessage", json={"chat_id": cid, "text": f"You said: {text}"})
|
||||
```
|
||||
|
||||
## Node.js Echo Bot
|
||||
|
||||
```javascript
|
||||
const TOKEN = process.env.BOT_TOKEN;
|
||||
const API = `http://localhost:7700/v1/bot/${TOKEN}`;
|
||||
let offset = 0;
|
||||
|
||||
(async () => {
|
||||
while (true) {
|
||||
try {
|
||||
const r = await (await fetch(`${API}/getUpdates`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({offset, timeout: 50})
|
||||
})).json();
|
||||
for (const u of r.result || []) {
|
||||
offset = u.update_id + 1;
|
||||
const {text, chat} = u.message || {};
|
||||
if (text && chat?.id)
|
||||
await fetch(`${API}/sendMessage`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({chat_id: chat.id, text: `Echo: ${text}`})
|
||||
});
|
||||
}
|
||||
} catch(e) { console.error(e); await new Promise(r => setTimeout(r, 3000)); }
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
## Bot Bridge (TG Library Compatibility)
|
||||
|
||||
For unmodified Telegram bots (python-telegram-bot, aiogram, Telegraf):
|
||||
|
||||
```bash
|
||||
python3 tools/bot-bridge.py --server http://localhost:7700 --token YOUR_TOKEN --port 8081
|
||||
```
|
||||
|
||||
Then point your TG bot at the bridge:
|
||||
```python
|
||||
# python-telegram-bot
|
||||
from telegram import Bot
|
||||
bot = Bot(token="TOKEN", base_url="http://localhost:8081/botTOKEN")
|
||||
|
||||
# Telegraf (Node.js)
|
||||
const bot = new Telegraf("TOKEN", { telegram: { apiRoot: "http://localhost:8081" } })
|
||||
```
|
||||
|
||||
The bridge translates numeric chat_id ↔ fingerprints automatically.
|
||||
|
||||
## Differences from Telegram
|
||||
|
||||
| Feature | Telegram | featherChat |
|
||||
|---------|----------|-------------|
|
||||
| chat_id | integer | string fp, numeric, or 0x ETH (all accepted) |
|
||||
| User→bot messages | plaintext | plaintext (auto-detected by client) |
|
||||
| Bot creation | @BotFather chat | @botfather chat (same flow) |
|
||||
| getUpdates timeout | up to 50s | up to 50s |
|
||||
| from.id | integer | integer (per-bot unique hash, no raw fp exposed) |
|
||||
| File upload | multipart | JSON reference (v1) |
|
||||
| Inline keyboards | full | stored + delivered, no popup |
|
||||
| Webhooks | HTTPS POST | HTTP POST (delivered live) |
|
||||
| parse_mode HTML | rendered | rendered in web client |
|
||||
| Media groups | yes | not yet |
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. **Always use offset** in getUpdates — without it you reprocess messages
|
||||
2. **chat_id** — use `msg.chat.id` (numeric, per-bot unique) for replies
|
||||
3. **Bot names** must end with `bot`, `Bot`, or `_bot`
|
||||
4. **Only @botfather** can create bots — direct API registration requires botfather_token
|
||||
5. **Server needs --enable-bots** — without it all bot endpoints return 403
|
||||
6. **Plaintext by default** — user clients auto-detect bot aliases and skip E2E
|
||||
7. **E2E bots** — register with `e2e:true` + bundle for encrypted sessions (advanced)
|
||||
210
warzone/docs/LLM_HELP.md
Normal file
210
warzone/docs/LLM_HELP.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# featherChat Help Reference
|
||||
|
||||
featherChat (codename: warzone) = E2E encrypted messenger. TUI client, web client (WASM), federated servers. Crypto: X3DH key exchange + Double Ratchet. Identity = Ed25519 keypair from 24-word seed.
|
||||
|
||||
## Commands
|
||||
|
||||
cmd | action | example
|
||||
--- | --- | ---
|
||||
/help, /? | show help | /help
|
||||
/info | show your fp | /info
|
||||
/eth | show ETH addr | /eth
|
||||
/seed | show 24-word recovery mnemonic | /seed
|
||||
/peer <addr>, /p | set DM peer | /peer abc123 or /peer @alice
|
||||
/reply, /r | reply to last DM sender | /r
|
||||
/dm | switch to DM mode (clear peer) | /dm
|
||||
/contacts, /c | list contacts + msg counts | /c
|
||||
/history, /h [fp] | show conversation history (50 msgs) | /h abc123
|
||||
/alias <name> | register alias for yourself | /alias alice
|
||||
/aliases | list all registered aliases | /aliases
|
||||
/unalias | remove your alias | /unalias
|
||||
/friend | list friends + online status | /friend
|
||||
/friend <addr> | add friend | /friend @bob
|
||||
/unfriend <addr> | remove friend | /unfriend @bob
|
||||
/devices | list active device sessions | /devices
|
||||
/kick <id> | kick a device session | /kick dev_abc
|
||||
/g <name> | switch to group (auto-join) | /g ops
|
||||
/gcreate <name> | create group | /gcreate ops
|
||||
/gjoin <name> | join group | /gjoin ops
|
||||
/glist | list all groups | /glist
|
||||
/gleave | leave current group | /gleave
|
||||
/gkick <fp> | kick member (creator only) | /gkick abc123
|
||||
/gmembers | list group members + status | /gmembers
|
||||
/file <path> | send file (max 10MB, 64KB chunks) | /file ./doc.pdf
|
||||
/quit, /q | exit | /q
|
||||
|
||||
Navigation: PageUp/PageDown scroll msgs, Up/Down scroll by 1 (empty input), Ctrl+C or Esc quit.
|
||||
|
||||
## Addressing
|
||||
|
||||
Format | Example | Notes
|
||||
--- | --- | ---
|
||||
Fingerprint | abc123def456... | hex string, derived from Ed25519 pubkey
|
||||
ETH address | 0x742d35Cc... | derived from same seed, checksum format
|
||||
@alias | @alice | 1-32 alphanum chars, globally unique, 365d TTL
|
||||
|
||||
All 3 formats work in /peer. Aliases resolve to fp via server. One alias per user. Register with /alias, recover with recovery key.
|
||||
|
||||
Bot alias reservation: names ending in Bot, bot, or _bot are reserved for the Bot API. Non-bot users cannot register these aliases.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. `warzone init` -- generates seed, saves identity.seed, prints 24-word mnemonic. WRITE IT DOWN.
|
||||
2. `warzone register --server https://srv.example.com` -- uploads prekey bundle to srv
|
||||
3. `warzone tui --server https://srv.example.com` -- opens TUI, connects WebSocket
|
||||
4. `/peer @alice` or `/peer <fingerprint>` -- set recipient
|
||||
5. Type msg, press Enter -- encrypted + sent
|
||||
|
||||
Recovery: `warzone recover` -- enter 24 words to restore identity on new device.
|
||||
|
||||
## Groups
|
||||
|
||||
- /gcreate <name> -- create, you become creator + first member
|
||||
- /gjoin <name> -- join existing (or auto-join via /g <name>)
|
||||
- type msg in group mode -- fan-out encrypted per-member (sender keys)
|
||||
- /gleave -- leave current group
|
||||
- /gmembers -- shows fp, alias, online status, creator flag
|
||||
- /gkick <fp> -- creator only, removes member
|
||||
|
||||
Groups auto-create on join if they don't exist. Server fans out per-member encrypted msgs.
|
||||
|
||||
## Files
|
||||
|
||||
/file <path> -- sends to current peer/group. Max 10MB. Auto-chunked at 64KB. Includes filename, size, SHA-256 hash. Receiver auto-reassembles.
|
||||
|
||||
## Friends
|
||||
|
||||
- /friend -- list all friends with online/offline status
|
||||
- /friend <addr> -- add (fp, ETH, or @alias)
|
||||
- /unfriend <addr> -- remove
|
||||
- Friend list stored encrypted on srv (only you can decrypt with your seed)
|
||||
- Shows alias resolution + presence status
|
||||
|
||||
## Devices
|
||||
|
||||
- /devices -- list active WS connections (device_id, connected_at)
|
||||
- /kick <device_id> -- revoke specific device
|
||||
- Max 5 concurrent device sessions
|
||||
- /devices/revoke-all API endpoint = panic button (kills all except current)
|
||||
|
||||
## Security
|
||||
|
||||
- Seed = 24-word BIP39 mnemonic = master key. Derives Ed25519 identity + ETH wallet.
|
||||
- NEVER share seed. Only way to recover account.
|
||||
- X3DH key exchange establishes sessions. Double Ratchet provides forward secrecy.
|
||||
- All DMs E2E encrypted. Group msgs encrypted per-member.
|
||||
- Server sees: metadata (who talks to whom, timestamps), encrypted blobs, presence.
|
||||
- Server cannot read msg content.
|
||||
- Pre-keys: signed pre-key + 10 one-time pre-keys uploaded on register.
|
||||
- Bot msgs: clients auto-detect bot aliases, send plaintext (no E2E). Server can read bot msgs.
|
||||
- E2E bots possible (register with seed+bundle) but standard bots are plaintext.
|
||||
|
||||
## Federation
|
||||
|
||||
- 2 servers connected via persistent WebSocket
|
||||
- Config: JSON file with server_id, shared_secret, peer URL
|
||||
- Messages auto-route across servers (srv checks remote presence)
|
||||
- Aliases globally unique across federation
|
||||
- @alias resolution checks local first, then federated peer
|
||||
- Same client commands work regardless of which srv peer is on
|
||||
- Auto-reconnects on connection failure
|
||||
|
||||
## Web Client
|
||||
|
||||
- Browser access at server root URL (/)
|
||||
- WASM-compiled client, same crypto as TUI
|
||||
- PWA: installable, offline-capable (service worker caches shell)
|
||||
- Same E2E encryption as native client
|
||||
- Deep links: navigate to specific peers/groups via URL
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Problem | Cause | Fix
|
||||
--- | --- | ---
|
||||
"peer not registered" | recipient hasn't run register yet | they need to `warzone register`
|
||||
"session reset" | crypto session re-established | normal after key rotation or recovery, msgs continue
|
||||
"connection lost" | WS disconnected | auto-reconnects, no action needed
|
||||
"alias already taken" | someone else has it | pick different name or wait for 365d expiry + 30d grace
|
||||
"not a member" | sending to group you left | /gjoin <name> first
|
||||
"invalid token" | bot token expired or wrong | re-register bot
|
||||
"file too large" | over 10MB | split file manually
|
||||
no prekeys available | recipient's one-time prekeys exhausted | they need to re-register or come online
|
||||
|
||||
## Bot API (Telegram-compatible)
|
||||
|
||||
### Creating a Bot
|
||||
|
||||
Server must run with `--enable-bots`. Then in chat:
|
||||
```
|
||||
/peer @botfather
|
||||
/newbot MyWeatherBot
|
||||
→ BotFather replies with token
|
||||
```
|
||||
|
||||
BotFather commands: /newbot, /mybots, /deletebot <name>, /token <name>, /help
|
||||
|
||||
Bot names must end with bot/Bot/_bot. Only @botfather can create bots.
|
||||
|
||||
### Plaintext Bot Messaging
|
||||
|
||||
Clients auto-detect bot aliases (names ending in Bot/bot/_bot) and send messages unencrypted (plaintext JSON). No E2E session is established for standard bot interactions.
|
||||
|
||||
### E2E Bot Option
|
||||
|
||||
Bots can optionally participate in E2E encryption by registering with a seed and prekey bundle. Pass `e2e: true` + `bundle` + `eth_address` in the registration request. Users messaging an E2E bot establish a normal X3DH session.
|
||||
|
||||
### Bot Bridge
|
||||
|
||||
`tools/bot-bridge.py` provides Telegram library compatibility. It translates between featherChat Bot API and standard TG bot libraries (python-telegram-bot, aiogram, Telegraf).
|
||||
|
||||
### Endpoints
|
||||
|
||||
|Endpoint|Method|Body|
|
||||
|---|---|---|
|
||||
|/bot/:token/getMe|GET|--|
|
||||
|/bot/:token/getUpdates|POST|{"timeout":50}|
|
||||
|/bot/:token/sendMessage|POST|{"chat_id":"<fp_or_numeric>","text":"Hello","parse_mode":"HTML"}|
|
||||
|/bot/:token/setWebhook|POST|{"url":"https://..."}|
|
||||
|/bot/:token/deleteWebhook|POST|--|
|
||||
|/bot/:token/getWebhookInfo|GET|--|
|
||||
|
||||
- Token format: fp_prefix:random_hex
|
||||
- getUpdates: long-poll (max 50s), returns then deletes queued msgs
|
||||
- sendMessage: plaintext JSON, NOT E2E encrypted (unless E2E bot)
|
||||
- Bot msgs delivered via same routing (WS push or DB queue)
|
||||
- Webhooks: updates are delivered live to the registered URL (POST with JSON body)
|
||||
- chat_id: accepts hex fingerprint or numeric ID (TG compatibility)
|
||||
- parse_mode: `HTML` renders basic HTML tags (<b>, <i>, <code>, <a>) in clients
|
||||
- from.id is per-bot unique numeric (bots can't correlate users cross-bot, no raw fingerprint exposed)
|
||||
|
||||
Update types in getUpdates:
|
||||
- Encrypted msg: text=null, raw_encrypted=base64
|
||||
- Bot msg (plaintext): text="actual text", from.is_bot=true
|
||||
- Call signal: text="/call_Offer", call_signal={type,payload}
|
||||
- File: document={file_name,file_size}
|
||||
|
||||
Echo bot (Python):
|
||||
```python
|
||||
import requests, time
|
||||
TOKEN = "your_token"
|
||||
API = f"http://srv:7700/v1/bot/{TOKEN}"
|
||||
while True:
|
||||
for u in requests.post(f"{API}/getUpdates",json={"timeout":50}).json().get("result",[]):
|
||||
m = u["message"]
|
||||
if m.get("text"): requests.post(f"{API}/sendMessage",json={"chat_id":m["chat"]["id"],"text":"Echo: "+m["text"]})
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
## Server API (other endpoints)
|
||||
|
||||
- POST /v1/register -- upload prekey bundle
|
||||
- GET /v1/keys/:fp -- fetch prekeys for peer
|
||||
- POST /v1/send -- send encrypted msg
|
||||
- GET /v1/receive/:fp -- poll msgs (WS preferred)
|
||||
- WS /v1/ws?fp=<fp>&token=<tok> -- real-time connection
|
||||
- GET /v1/presence/:fp -- check online status
|
||||
- GET/POST /v1/friends -- encrypted friend list
|
||||
- GET /v1/devices -- list sessions
|
||||
- POST /v1/devices/:id/kick -- kick device
|
||||
- Alias routes under /v1/alias/*
|
||||
- Group routes under /v1/groups/*
|
||||
@@ -1,6 +1,6 @@
|
||||
# Warzone Messenger (featherChat) — Progress Report
|
||||
|
||||
**Current Version:** 0.0.20
|
||||
**Current Version:** 0.0.21
|
||||
**Last Updated:** 2026-03-28
|
||||
|
||||
---
|
||||
@@ -68,16 +68,42 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
|
||||
| Reply shortcut (/r, /reply) | 0.0.19 | Done |
|
||||
| 28 protocol tests | 0.0.20 | Done |
|
||||
|
||||
### Phase 2.5 — WZP Integration & TUI Overhaul (v0.0.21)
|
||||
|
||||
| Feature | Version | Status |
|
||||
|------------------------------------------|---------|--------|
|
||||
| warzone-protocol standalone-importable | 0.0.21 | Done |
|
||||
| CallSignal WireMessage variant | 0.0.21 | Done |
|
||||
| Auth token validation endpoint | 0.0.21 | Done |
|
||||
| TUI modular split (7 modules from 1) | 0.0.21 | Done |
|
||||
| TUI message timestamps [HH:MM] | 0.0.21 | Done |
|
||||
| TUI message scrolling (PageUp/Down/arrows) | 0.0.21 | Done |
|
||||
| TUI connection status indicator | 0.0.21 | Done |
|
||||
| TUI unread message badge | 0.0.21 | Done |
|
||||
| TUI /help command | 0.0.21 | Done |
|
||||
| TUI terminal bell on incoming DM | 0.0.21 | Done |
|
||||
| 44 TUI unit tests (types, input, draw) | 0.0.21 | Done |
|
||||
| Call state management (server) | 0.0.21 | Done |
|
||||
| WS call signaling awareness | 0.0.21 | Done |
|
||||
| Group-to-room mapping + group call API | 0.0.21 | Done |
|
||||
| Presence/online status API | 0.0.21 | Done |
|
||||
| Missed call notifications | 0.0.21 | Done |
|
||||
| WZP relay config + CORS | 0.0.21 | Done |
|
||||
| WZP submodule: all 9 S-tasks done | 0.0.21 | Done |
|
||||
| 72 total tests (28 protocol + 44 client) | 0.0.21 | Done |
|
||||
|
||||
---
|
||||
|
||||
## Current Version: v0.0.20
|
||||
## Current Version: v0.0.21
|
||||
|
||||
### Codebase Statistics
|
||||
|
||||
| Metric | Value |
|
||||
|-------------------|--------------------------------|
|
||||
| Crates | 5 (protocol, server, client, wasm, mule) |
|
||||
| Protocol tests | 28 |
|
||||
| Total tests | 72 (28 protocol + 44 client) |
|
||||
| Server routes | 12 files, 9 new endpoints |
|
||||
| TUI modules | 7 (split from 1 monolith) |
|
||||
| Rust edition | 2021 |
|
||||
| Min Rust version | 1.75 |
|
||||
| License | MIT |
|
||||
@@ -91,7 +117,7 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
|
||||
| prekey | Signed + one-time pre-keys |
|
||||
| x3dh | Extended Triple Diffie-Hellman |
|
||||
| ratchet | Double Ratchet state machine |
|
||||
| message | WireMessage (7 variants), content types|
|
||||
| message | WireMessage (8 variants incl. CallSignal)|
|
||||
| sender_keys | Sender Key encrypt/decrypt/rotate |
|
||||
| history | Encrypted backup format |
|
||||
| ethereum | secp256k1, Keccak-256, EIP-55 |
|
||||
@@ -121,18 +147,29 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
|
||||
|
||||
## Test Suite
|
||||
|
||||
28 tests across the protocol crate:
|
||||
72 tests across protocol + client crates:
|
||||
|
||||
### Protocol Tests (28)
|
||||
|
||||
| Module | Tests | Coverage |
|
||||
|---------------|-------|---------------------------------------------|
|
||||
| identity | 3 | Deterministic derivation, mnemonic roundtrip, fingerprint format |
|
||||
| crypto | 4 | AEAD roundtrip, wrong key, wrong AAD, HKDF determinism |
|
||||
| x3dh | ~4 | Initiate/respond, shared secret match, with/without OTPK |
|
||||
| ratchet | ~6 | Encrypt/decrypt, out-of-order, multiple messages, ping-pong |
|
||||
| x3dh | 1 | Shared secret match between Alice and Bob |
|
||||
| ratchet | 5 | Basic, bidirectional, multiple, out-of-order, 100 messages |
|
||||
| sender_keys | 4 | Basic encrypt/decrypt, multiple messages, rotation, old key rejection |
|
||||
| ethereum | 5 | Deterministic derivation, address format, checksum, sign/verify, different seeds |
|
||||
| history | 2 | Roundtrip encryption, wrong seed rejection |
|
||||
| prekey | ~2 | Bundle generation, signature verification |
|
||||
| prekey | 3 | SPK verify, tamper detection, OTPK generation |
|
||||
| mnemonic | 1 | BIP39 roundtrip |
|
||||
|
||||
### Client Tests (44)
|
||||
|
||||
| Module | Tests | Coverage |
|
||||
|---------------|-------|---------------------------------------------|
|
||||
| tui::types | 10 | App init, scroll/connected defaults, ChatLine timestamps, normfp, add_message |
|
||||
| tui::input | 25 | 8 text editing, 7 cursor movement, 2 quit, 8 scroll keybindings |
|
||||
| tui::draw | 9 | Rendering smoke, header fingerprint, connection dot (red/green), timestamps, scroll show/hide, unread badge |
|
||||
|
||||
---
|
||||
|
||||
@@ -224,11 +261,14 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
|
||||
- Cross-compilation CI (Linux x86/ARM, macOS, Windows, WASM)
|
||||
- PWA: service worker, offline shell, install prompt
|
||||
|
||||
### Priority Order
|
||||
### Priority Order (Updated v0.0.21)
|
||||
|
||||
1. Federation (Phase 3) — enables multi-server deployment
|
||||
2. Mule protocol (Phase 4) — core differentiator for warzone use
|
||||
3. Sealed sender (Phase 6) — strongest metadata privacy
|
||||
4. Push notifications (Phase 7) — usability for mobile/desktop
|
||||
5. Transport fallbacks (Phase 5) — Bluetooth, LoRa
|
||||
6. Polish (Phase 7) — rate limiting, admin tools, CI
|
||||
1. **Security (FC-P1)** — auth enforcement, rate limiting, device revocation
|
||||
2. **TUI call integration (FC-P2)** — /call, /accept, /hangup commands
|
||||
3. **Web call integration (FC-P3)** — WASM CallSignal + browser call UI
|
||||
4. **Protocol hardening (FC-P4)** — session/message versioning
|
||||
5. Federation (Phase 3) — multi-server deployment
|
||||
6. Mule protocol (Phase 4) — physical delivery
|
||||
7. Polish (FC-P6) — search, reactions, typing indicators
|
||||
|
||||
See `TASK_PLAN.md` for the detailed task breakdown with IDs and dependencies.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Warzone Messenger (featherChat) — Security Model & Threat Analysis
|
||||
|
||||
**Version:** 0.0.20
|
||||
**Last Updated:** 2026-03-28
|
||||
**Version:** 0.0.21
|
||||
**Last Updated:** 2026-03-29
|
||||
|
||||
---
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
| Session state | Encrypted backup (HKDF + ChaCha20-Poly1305) |
|
||||
| Pre-key authenticity | Ed25519 signature on signed pre-keys |
|
||||
| Key exchange integrity | X3DH with 3-4 DH operations |
|
||||
| Friend list | E2E encrypted blob (ChaCha20 + HKDF-derived key) |
|
||||
| API write operations | Bearer token middleware on all POST routes |
|
||||
| Device sessions | Kick/revoke-all, max 5 WS per fingerprint |
|
||||
| Bot aliases | Reserved suffixes (Bot/bot/_bot) enforced |
|
||||
|
||||
### What Is NOT Protected (Current)
|
||||
|
||||
@@ -32,6 +36,7 @@
|
||||
| Message sizes | Server sees encrypted message sizes |
|
||||
| Online/offline status | Server knows when clients connect via WebSocket|
|
||||
| IP addresses | Server sees client IP addresses |
|
||||
| Bot messages | Plaintext (not E2E) in v1 — bots don't hold ratchet sessions |
|
||||
|
||||
### Trust Boundaries
|
||||
|
||||
@@ -63,6 +68,34 @@
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Challenge-response: Ed25519 signature over random challenge
|
||||
- Bearer tokens: 7-day TTL, required on all write endpoints
|
||||
- Auth middleware: `AuthFingerprint` extractor returns 401 on invalid/missing token
|
||||
- Bot tokens: separate namespace (`bot:<token>`), validated per-request
|
||||
- Federation: shared secret compared on WS auth frame
|
||||
|
||||
Protected endpoints (require bearer token):
|
||||
- messages/send, groups/*, aliases/*, calls/*, devices/*, friends, presence/batch
|
||||
|
||||
Public endpoints (no auth):
|
||||
- keys/:fp, messages/poll, groups GET, alias/resolve, resolve/:address, bot/*
|
||||
|
||||
### Rate Limiting & Abuse Prevention
|
||||
|
||||
- Global: 200 concurrent requests (tower ConcurrencyLimitLayer)
|
||||
- Per-fingerprint: max 5 WebSocket connections
|
||||
- Stale connections auto-cleaned on new registrations
|
||||
- Federation: auto-reconnect with 3s backoff (no amplification)
|
||||
|
||||
### Session Recovery
|
||||
|
||||
On ratchet decryption failure:
|
||||
1. Corrupted session deleted from local DB
|
||||
2. Warning shown: "[session reset]"
|
||||
3. Next KeyExchange re-establishes the session automatically
|
||||
|
||||
---
|
||||
|
||||
## Cryptographic Primitives
|
||||
|
||||
@@ -1,37 +1,63 @@
|
||||
# Warzone Server -- Operation & Administration
|
||||
# Warzone Server -- Administration Guide
|
||||
|
||||
**Version 0.0.21**
|
||||
|
||||
---
|
||||
|
||||
## 1. Building
|
||||
|
||||
The server is part of the Cargo workspace. From the workspace root:
|
||||
### Local Build
|
||||
|
||||
From the workspace root:
|
||||
|
||||
```bash
|
||||
# Debug build
|
||||
# Debug
|
||||
cargo build -p warzone-server
|
||||
|
||||
# Release build (recommended for deployment)
|
||||
# Release (recommended for deployment)
|
||||
cargo build -p warzone-server --release
|
||||
```
|
||||
|
||||
The resulting binary is at `target/release/warzone-server` (or
|
||||
`target/debug/warzone-server`). It is a single statically-linked binary with
|
||||
no runtime dependencies beyond libc.
|
||||
Binary output: `target/release/warzone-server`.
|
||||
|
||||
### Cross-Compile for Linux (x86_64)
|
||||
|
||||
The `scripts/build-linux.sh` script spins up a Hetzner Cloud VPS, builds
|
||||
Linux release binaries, and pulls them back to `target/linux-x86_64/`.
|
||||
|
||||
```bash
|
||||
# Full pipeline: build + deploy to all production servers + destroy VM
|
||||
./scripts/build-linux.sh --ship
|
||||
|
||||
# Step-by-step:
|
||||
./scripts/build-linux.sh --prepare # create VM, install deps, upload source
|
||||
./scripts/build-linux.sh --build # compile release binaries on the VM
|
||||
./scripts/build-linux.sh --transfer # download binaries to target/linux-x86_64/
|
||||
./scripts/build-linux.sh --destroy # delete the VM
|
||||
|
||||
# Or all three build steps at once (VM persists):
|
||||
./scripts/build-linux.sh --all
|
||||
```
|
||||
|
||||
### Minimum Rust Version
|
||||
|
||||
Rust 1.75 or later (set via `rust-version = "1.75"` in `Cargo.toml`).
|
||||
Rust 1.75 or later (`rust-version = "1.75"` in `Cargo.toml`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Running
|
||||
|
||||
### Basic
|
||||
|
||||
```bash
|
||||
# Default: bind 0.0.0.0:7700, data in ./warzone-data
|
||||
# Defaults: bind 0.0.0.0:7700, data in ./warzone-data
|
||||
./warzone-server
|
||||
|
||||
# Custom bind address and data directory
|
||||
./warzone-server --bind 127.0.0.1:8080 --data-dir /var/lib/warzone
|
||||
./warzone-server --bind 0.0.0.0:7700 --data-dir ./data
|
||||
|
||||
# With federation enabled
|
||||
./warzone-server --federation federation.json
|
||||
```
|
||||
|
||||
### CLI Flags
|
||||
@@ -39,214 +65,390 @@ Rust 1.75 or later (set via `rust-version = "1.75"` in `Cargo.toml`).
|
||||
| Flag | Short | Default | Description |
|
||||
|------|-------|---------|-------------|
|
||||
| `--bind` | `-b` | `0.0.0.0:7700` | Address and port to listen on |
|
||||
| `--data-dir` | `-d` | `./warzone-data` | Directory for sled database files |
|
||||
| `--data-dir` | `-d` | `./warzone-data` | Directory for the sled database |
|
||||
| `--federation` | `-f` | *(none)* | Path to federation JSON config file |
|
||||
| `--enable-bots` | | *(off)* | Enable Bot API and auto-create BotFather on startup |
|
||||
|
||||
### Logging
|
||||
### Environment Variables
|
||||
|
||||
The server uses `tracing-subscriber`. Control log level with the `RUST_LOG`
|
||||
environment variable:
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `RUST_LOG` | `warn` (production) | Log filter. Examples: `info`, `warzone_server=debug`, `trace` |
|
||||
| `WZP_RELAY_ADDR` | *(none)* | WZP voice relay address advertised to clients |
|
||||
|
||||
### Per-Instance Configuration (`server.env`)
|
||||
|
||||
Each server instance can use a `server.env` file for per-instance settings.
|
||||
Place it in the working directory or alongside the binary. This allows
|
||||
different instances to have different configurations (e.g., bots enabled on
|
||||
one server but not another).
|
||||
|
||||
Example `server.env`:
|
||||
```
|
||||
RUST_LOG=info
|
||||
WZP_RELAY_ADDR=relay.example.com:3478
|
||||
ENABLE_BOTS=true
|
||||
```
|
||||
|
||||
### systemd Service
|
||||
|
||||
A production-ready unit file is provided at `deploy/warzone-server.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Warzone Messenger Server (featherChat)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=warzone
|
||||
Group=warzone
|
||||
WorkingDirectory=/home/warzone
|
||||
ExecStart=/home/warzone/warzone-server --bind 0.0.0.0:7700 --data-dir /home/warzone/data --federation /home/warzone/federation.json
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
LimitNOFILE=65536
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/warzone/data
|
||||
PrivateTmp=yes
|
||||
|
||||
Environment=RUST_LOG=warn,warzone_server::federation=info
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Install and enable:
|
||||
|
||||
```bash
|
||||
RUST_LOG=info ./warzone-server
|
||||
RUST_LOG=warzone_server=debug ./warzone-server
|
||||
RUST_LOG=trace ./warzone-server # very verbose
|
||||
sudo cp deploy/warzone-server.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now warzone-server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API Reference
|
||||
## 3. Configuration
|
||||
|
||||
All API endpoints are under the `/v1` prefix. The web UI is served at `/`.
|
||||
### Federation JSON
|
||||
|
||||
### Health Check
|
||||
Enable federation by passing `--federation <path>` on startup. The config
|
||||
file specifies the local server identity, peer connection details, and a
|
||||
shared secret for authentication.
|
||||
|
||||
```
|
||||
GET /v1/health
|
||||
```
|
||||
**Format** (see `federation.example.json`):
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "0.1.0"
|
||||
"server_id": "alpha",
|
||||
"shared_secret": "change-me-to-a-long-random-string-shared-between-both-servers",
|
||||
"peer": {
|
||||
"id": "bravo",
|
||||
"url": "http://10.0.0.2:7700"
|
||||
},
|
||||
"presence_interval_secs": 5
|
||||
}
|
||||
```
|
||||
|
||||
Use this for monitoring, load balancer health probes, and uptime checks.
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `server_id` | Unique name for this server (e.g. `"alpha"`) |
|
||||
| `shared_secret` | Pre-shared secret; must match on both sides |
|
||||
| `peer.id` | The remote server's `server_id` |
|
||||
| `peer.url` | HTTP base URL of the remote server |
|
||||
| `presence_interval_secs` | How often to broadcast online-user lists (default 5) |
|
||||
|
||||
---
|
||||
|
||||
### Register Key Bundle
|
||||
## 4. Federation
|
||||
|
||||
```
|
||||
POST /v1/keys/register
|
||||
Content-Type: application/json
|
||||
```
|
||||
Federation connects two Warzone servers over a persistent WebSocket so
|
||||
their users can communicate transparently.
|
||||
|
||||
### How It Works
|
||||
|
||||
- On startup, each server opens an outgoing WebSocket to its peer at
|
||||
`/v1/federation/ws` and authenticates with the shared secret.
|
||||
- The connection auto-reconnects on failure.
|
||||
- Presence (online fingerprints) is synced on the configured interval.
|
||||
- Messages to users on the remote server are forwarded automatically.
|
||||
|
||||
### Federated Features
|
||||
|
||||
| Feature | Behavior |
|
||||
|---------|----------|
|
||||
| **Key lookup proxy** | If a key bundle is not found locally, the server queries the peer |
|
||||
| **Message forwarding** | Messages addressed to a remote fingerprint are relayed over the WS |
|
||||
| **Alias resolution** | `/v1/resolve/:address` checks the peer if the alias is not local |
|
||||
| **Presence sync** | Each server broadcasts its online fingerprints to the peer |
|
||||
|
||||
### Two-Server Setup
|
||||
|
||||
**Server A** (`alpha`, e.g. `mequ`):
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"fingerprint": "a3f8:c912:44be:7d01",
|
||||
"bundle": [/* bincode-serialized PreKeyBundle as byte array */]
|
||||
"server_id": "alpha",
|
||||
"shared_secret": "s3cret-shared-between-both",
|
||||
"peer": { "id": "bravo", "url": "http://bravo-host:7700" },
|
||||
"presence_interval_secs": 5
|
||||
}
|
||||
```
|
||||
|
||||
The `bundle` field is a JSON array of unsigned bytes (the raw bincode
|
||||
serialization of a `PreKeyBundle`).
|
||||
**Server B** (`bravo`, e.g. `kh3rad3ree`):
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"ok": true
|
||||
"server_id": "bravo",
|
||||
"shared_secret": "s3cret-shared-between-both",
|
||||
"peer": { "id": "alpha", "url": "http://alpha-host:7700" },
|
||||
"presence_interval_secs": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:** stores the bundle in the `keys` sled tree, keyed by the
|
||||
fingerprint string. Overwrites any existing bundle for the same fingerprint.
|
||||
Both files use the same `shared_secret`. Each server's `peer.id` matches
|
||||
the other server's `server_id`.
|
||||
|
||||
### Federation Status Endpoint
|
||||
|
||||
```bash
|
||||
curl http://localhost:7700/v1/federation/status
|
||||
```
|
||||
|
||||
Returns JSON with connection state, peer info, and presence data.
|
||||
|
||||
---
|
||||
|
||||
### Fetch Key Bundle
|
||||
## 4b. Bot System
|
||||
|
||||
```
|
||||
GET /v1/keys/{fingerprint}
|
||||
### Enabling Bots
|
||||
|
||||
Start the server with `--enable-bots` to activate bot functionality. Without
|
||||
this flag, all bot endpoints return 403.
|
||||
|
||||
```bash
|
||||
./warzone-server --bind 0.0.0.0:7700 --enable-bots
|
||||
```
|
||||
|
||||
**Path parameter:** the fingerprint string, e.g. `a3f8:c912:44be:7d01`.
|
||||
### BotFather Auto-Creation
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"fingerprint": "a3f8:c912:44be:7d01",
|
||||
"bundle": "base64-encoded-bincode-bytes..."
|
||||
}
|
||||
```
|
||||
On first start with `--enable-bots`, the server auto-creates the `@botfather`
|
||||
bot. The BotFather token is printed to the server logs. Users interact with
|
||||
`@botfather` to register new bots.
|
||||
|
||||
The `bundle` value is standard base64-encoded bincode. The client decodes
|
||||
base64, then deserializes with bincode to recover the `PreKeyBundle`.
|
||||
### Per-Instance Bot Toggle
|
||||
|
||||
**Response (404):** returned if no bundle is registered for the fingerprint.
|
||||
Bot support can be enabled independently per server instance:
|
||||
|
||||
| Instance | Bots | Config |
|
||||
|----------|------|--------|
|
||||
| mequ | Disabled | No `--enable-bots` flag |
|
||||
| kh3rad3ree | Enabled | `--enable-bots` flag set |
|
||||
|
||||
### Bot Webhook Delivery
|
||||
|
||||
When a bot has a webhook configured (via `setWebhook`), incoming messages are
|
||||
delivered live to the webhook URL via HTTP POST instead of being queued for
|
||||
`getUpdates` polling. This is integrated into the standard message routing
|
||||
pipeline -- `deliver_or_queue` checks for webhook configuration before
|
||||
queueing.
|
||||
|
||||
---
|
||||
|
||||
### Send Message
|
||||
## 5. API Reference
|
||||
|
||||
```
|
||||
POST /v1/messages/send
|
||||
Content-Type: application/json
|
||||
```
|
||||
All endpoints are prefixed with `/v1`. The web UI is served at `/`.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"to": "b7d1:e845:0022:9f3a",
|
||||
"message": [/* bincode-serialized WireMessage as byte array */]
|
||||
}
|
||||
```
|
||||
### Notation
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:** the message bytes are stored in the `messages` sled tree under
|
||||
the key `queue:{recipient_fingerprint}:{uuid}`. The UUID is generated
|
||||
server-side to ensure unique keys.
|
||||
|
||||
The server does NOT parse, validate, or inspect the message contents. It is an
|
||||
opaque blob.
|
||||
- **Auth** = requires `Authorization: Bearer <token>` header (write routes).
|
||||
- **Public** = no authentication needed (read routes).
|
||||
|
||||
---
|
||||
|
||||
### Poll Messages
|
||||
### Health
|
||||
|
||||
```
|
||||
GET /v1/messages/poll/{fingerprint}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
[
|
||||
"base64-encoded-message-1",
|
||||
"base64-encoded-message-2"
|
||||
]
|
||||
```
|
||||
|
||||
Returns a JSON array of base64-encoded message blobs. Each blob is a
|
||||
bincode-serialized `WireMessage`. An empty array means no messages.
|
||||
|
||||
**Behavior:** scans the `messages` sled tree for all keys prefixed with
|
||||
`queue:{fingerprint}`. Messages are NOT deleted by polling; they remain until
|
||||
explicitly acknowledged.
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/health` | Public | Health check; returns `{"status":"ok"}` |
|
||||
|
||||
---
|
||||
|
||||
### Acknowledge Message
|
||||
### Keys
|
||||
|
||||
```
|
||||
DELETE /v1/messages/{id}/ack
|
||||
```
|
||||
|
||||
**Path parameter:** the message storage key (currently the full sled key
|
||||
including the `queue:` prefix and UUID).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:** removes the message from the `messages` tree.
|
||||
|
||||
**Note:** the current implementation requires knowing the exact sled key to
|
||||
acknowledge. A proper message-ID-based index is planned for Phase 2.
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/v1/keys/register` | Public | Register a pre-key bundle |
|
||||
| POST | `/v1/keys/replenish` | Public | Upload additional one-time pre-keys |
|
||||
| GET | `/v1/keys/:fingerprint` | Public | Fetch a key bundle (falls back to federation peer) |
|
||||
| GET | `/v1/keys/list` | Public | List all registered fingerprints |
|
||||
| GET | `/v1/keys/:fingerprint/otpk-count` | Public | Remaining one-time pre-key count |
|
||||
| GET | `/v1/keys/:fingerprint/devices` | Public | List devices for a fingerprint |
|
||||
|
||||
---
|
||||
|
||||
## 4. Web UI
|
||||
### Messages
|
||||
|
||||
The server serves a single-page web client at the root path `/`.
|
||||
|
||||
```
|
||||
GET /
|
||||
```
|
||||
|
||||
Returns an HTML page with embedded CSS and JavaScript. The web client provides:
|
||||
|
||||
- **Identity generation:** generates a random 32-byte seed in the browser
|
||||
using `crypto.getRandomValues()`.
|
||||
- **Identity recovery:** paste a hex-encoded seed to recover.
|
||||
- **Fingerprint display:** shows the user's fingerprint in the header.
|
||||
- **Key registration:** automatically registers a public key with the server
|
||||
on entry.
|
||||
- **Message polling:** polls `/v1/messages/poll/{fingerprint}` every 5 seconds.
|
||||
- **Slash commands:** `/help`, `/info`, `/seed`.
|
||||
|
||||
### Web Client Limitations
|
||||
|
||||
- Uses ECDH P-256 (Web Crypto API) instead of X25519. Cross-client
|
||||
compatibility with the CLI is not yet implemented. (Phase 2)
|
||||
- Does not use BIP39 mnemonics; seed is displayed as hex.
|
||||
- Message decryption is not yet wired (Double Ratchet in JS is TODO).
|
||||
- The seed is stored in `localStorage` (unencrypted).
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/v1/messages/send` | Auth | Send an encrypted message blob |
|
||||
| GET | `/v1/messages/poll/:fingerprint` | Public | Poll queued messages |
|
||||
| DELETE | `/v1/messages/:id/ack` | Public | Acknowledge (delete) a message |
|
||||
|
||||
---
|
||||
|
||||
## 5. Database
|
||||
### Groups
|
||||
|
||||
The server uses **sled** (embedded key-value store). All data lives under the
|
||||
directory specified by `--data-dir`.
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/v1/groups/create` | Auth | Create a group |
|
||||
| POST | `/v1/groups/:name/join` | Auth | Join a group |
|
||||
| POST | `/v1/groups/:name/send` | Auth | Send a message to a group |
|
||||
| POST | `/v1/groups/:name/leave` | Auth | Leave a group |
|
||||
| POST | `/v1/groups/:name/kick` | Auth | Kick a member from a group |
|
||||
| GET | `/v1/groups` | Public | List all groups |
|
||||
| GET | `/v1/groups/:name` | Public | Get group details |
|
||||
| GET | `/v1/groups/:name/members` | Public | List members (includes online status) |
|
||||
|
||||
### Trees (Tables)
|
||||
---
|
||||
|
||||
| Tree | Key format | Value | Purpose |
|
||||
|------|-----------|-------|---------|
|
||||
| `keys` | fingerprint string (UTF-8 bytes) | bincode `PreKeyBundle` | Pre-key bundle storage |
|
||||
| `messages` | `queue:{fingerprint}:{uuid}` (UTF-8 bytes) | bincode `WireMessage` | Message queue |
|
||||
| `otpks` | (reserved) | (reserved) | One-time pre-key tracking (not yet used server-side) |
|
||||
### Aliases
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/v1/alias/register` | Auth | Register a human-readable alias |
|
||||
| POST | `/v1/alias/unregister` | Auth | Remove your alias |
|
||||
| POST | `/v1/alias/recover` | Auth | Transfer alias to a new fingerprint |
|
||||
| POST | `/v1/alias/renew` | Auth | Renew alias expiry |
|
||||
| POST | `/v1/alias/admin-remove` | Auth | Admin-remove an alias |
|
||||
| GET | `/v1/alias/resolve/:name` | Public | Resolve alias to fingerprint |
|
||||
| GET | `/v1/alias/list` | Public | List all registered aliases |
|
||||
| GET | `/v1/alias/whois/:fingerprint` | Public | Reverse-lookup: fingerprint to alias |
|
||||
|
||||
---
|
||||
|
||||
### Calls (WZP)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/v1/calls/initiate` | Auth | Start a 1:1 call |
|
||||
| POST | `/v1/calls/:id/end` | Auth | End an active call |
|
||||
| POST | `/v1/calls/missed` | Auth | Get missed calls for a fingerprint |
|
||||
| POST | `/v1/groups/:name/call` | Auth | Initiate a group call |
|
||||
| GET | `/v1/calls/:id` | Public | Get call details |
|
||||
| GET | `/v1/calls/active` | Public | List active calls |
|
||||
|
||||
---
|
||||
|
||||
### Devices
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/v1/devices/:id/kick` | Auth | Disconnect a specific device |
|
||||
| POST | `/v1/devices/revoke-all` | Auth | Disconnect all devices (optional keep one) |
|
||||
| GET | `/v1/devices` | Auth | List your connected devices |
|
||||
|
||||
---
|
||||
|
||||
### Presence
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/v1/presence/batch` | Auth | Batch-query presence for multiple fingerprints |
|
||||
| GET | `/v1/presence/:fingerprint` | Public | Check if a fingerprint is online |
|
||||
|
||||
---
|
||||
|
||||
### Friends
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/v1/friends` | Auth | Save friend list (encrypted blob) |
|
||||
| GET | `/v1/friends` | Auth | Retrieve saved friend list |
|
||||
|
||||
---
|
||||
|
||||
### Resolve
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/resolve/:address` | Public | Universal resolve: ETH address, alias, or fingerprint. Checks federation peer if not found locally. |
|
||||
|
||||
---
|
||||
|
||||
### WZP Voice Relay
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/wzp/relay-config` | Public | Get the WZP relay address for voice calls |
|
||||
|
||||
---
|
||||
|
||||
### Federation
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/v1/federation/status` | Public | Federation connection status and peer info |
|
||||
| GET | `/v1/federation/ws` | Internal | WebSocket endpoint for server-to-server communication |
|
||||
|
||||
---
|
||||
|
||||
### Bot API
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/v1/bot/register` | Auth | Register a bot; returns an API token |
|
||||
| GET | `/v1/bot/:token/getMe` | Token | Bot identity info |
|
||||
| POST | `/v1/bot/:token/getUpdates` | Token | Long-poll for new messages (Telegram-compatible) |
|
||||
| POST | `/v1/bot/:token/sendMessage` | Token | Send a message as the bot (Telegram-compatible) |
|
||||
|
||||
Bot tokens are scoped to the bot's fingerprint. The `getUpdates` and
|
||||
`sendMessage` endpoints follow the Telegram Bot API conventions so existing
|
||||
Telegram bot libraries can be adapted with minimal changes.
|
||||
|
||||
---
|
||||
|
||||
### WebSocket
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/v1/ws/:fingerprint` | Real-time message delivery. Clients receive instant push of new messages. |
|
||||
|
||||
---
|
||||
|
||||
### Web UI
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/` | Single-page WASM web client |
|
||||
| `/wasm/warzone_wasm.js` | WASM JavaScript bindings |
|
||||
| `/wasm/warzone_wasm_bg.wasm` | WASM binary |
|
||||
|
||||
---
|
||||
|
||||
## 6. Database
|
||||
|
||||
The server uses **sled** (embedded key-value store). All data lives under
|
||||
the `--data-dir` directory.
|
||||
|
||||
### Trees
|
||||
|
||||
| Tree | Purpose |
|
||||
|------|---------|
|
||||
| `keys` | Pre-key bundles (public keys only) |
|
||||
| `messages` | Queued encrypted message blobs |
|
||||
| `groups` | Group metadata and membership |
|
||||
| `aliases` | Human-readable alias mappings |
|
||||
| `tokens` | Authentication tokens (device sessions) |
|
||||
| `calls` | Call records (1:1 and group) |
|
||||
| `missed_calls` | Missed call notifications |
|
||||
| `friends` | Encrypted friend lists |
|
||||
| `eth_addresses` | Ethereum address to fingerprint mappings |
|
||||
|
||||
### Data Directory Structure
|
||||
|
||||
@@ -254,161 +456,123 @@ directory specified by `--data-dir`.
|
||||
warzone-data/
|
||||
db # sled database file
|
||||
conf # sled config
|
||||
blobs/ # sled blob storage (if any)
|
||||
blobs/ # sled blob storage
|
||||
snap.*/ # sled snapshots
|
||||
```
|
||||
|
||||
The exact file layout is managed by sled internally. The entire directory
|
||||
should be treated as a unit for backup.
|
||||
|
||||
### What the Server Stores
|
||||
|
||||
- **Pre-key bundles:** public keys only. The server never holds private keys.
|
||||
- **Encrypted message blobs:** opaque binary data. The server cannot read
|
||||
message contents.
|
||||
- **Metadata visible to server:** sender fingerprint, recipient fingerprint,
|
||||
message size, timestamps (implicit from storage order).
|
||||
The entire directory should be treated as a unit for backup. Stop the server
|
||||
before copying, or use filesystem-level snapshots (LVM, ZFS, btrfs).
|
||||
|
||||
---
|
||||
|
||||
## 6. Deployment
|
||||
## 7. Security
|
||||
|
||||
### Single Binary
|
||||
### Auth Middleware
|
||||
|
||||
The recommended deployment is a single `warzone-server` binary behind a
|
||||
reverse proxy for TLS termination.
|
||||
All write (POST) endpoints require a bearer token in the `Authorization`
|
||||
header. Tokens are issued during key registration and tied to a fingerprint.
|
||||
Read (GET) endpoints are public.
|
||||
|
||||
### Reverse Proxy (nginx)
|
||||
### Rate Limiting
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name wz.example.com;
|
||||
- **200 concurrent requests** (tower `ConcurrencyLimitLayer`)
|
||||
- **5 WebSocket connections per fingerprint** (multi-device cap)
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/wz.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/wz.example.com/privkey.pem;
|
||||
### Device Management
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:7700;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
Users can list connected devices, kick individual devices, or revoke all
|
||||
sessions via the `/v1/devices` endpoints. The `revoke-all` endpoint accepts
|
||||
an optional `keep_device_id` to keep the current device active.
|
||||
|
||||
# WebSocket support (for future real-time push)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
### What the Server Can See
|
||||
|
||||
When using a reverse proxy, bind the server to localhost only:
|
||||
| Data | Visible |
|
||||
|------|---------|
|
||||
| Message plaintext | No (E2E encrypted blobs) |
|
||||
| Sender/recipient fingerprints | Yes |
|
||||
| Message size and timing | Yes |
|
||||
| Public pre-key bundles | Yes (public by design) |
|
||||
| IP addresses | Yes (from HTTP) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Monitoring
|
||||
|
||||
### Logging
|
||||
|
||||
Control verbosity with `RUST_LOG`:
|
||||
|
||||
```bash
|
||||
./warzone-server --bind 127.0.0.1:7700
|
||||
RUST_LOG=warn ./warzone-server # production default
|
||||
RUST_LOG=info ./warzone-server # request-level logging
|
||||
RUST_LOG=warzone_server=debug ./warzone-server # server internals
|
||||
RUST_LOG=trace ./warzone-server # everything
|
||||
```
|
||||
|
||||
### systemd Service
|
||||
With systemd:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Warzone Messenger Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=warzone
|
||||
ExecStart=/usr/local/bin/warzone-server --bind 127.0.0.1:7700 --data-dir /var/lib/warzone
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=RUST_LOG=info
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```bash
|
||||
journalctl -u warzone-server -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Monitoring
|
||||
|
||||
### Health Endpoint
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:7700/v1/health
|
||||
# {"status":"ok","version":"0.1.0"}
|
||||
```
|
||||
|
||||
Use this for:
|
||||
- Load balancer health checks
|
||||
- Uptime monitoring (e.g., with `uptime-kuma`, Prometheus blackbox exporter)
|
||||
- Deployment verification
|
||||
### Federation Status
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
curl http://localhost:7700/v1/federation/status
|
||||
```
|
||||
|
||||
All request activity is logged via `tracing`. In production, pipe to a log
|
||||
aggregator or use `journalctl -u warzone-server`.
|
||||
Returns connection state, peer identity, and synced presence data.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Considerations
|
||||
## 9. Deploy Scripts
|
||||
|
||||
### The Server Is a Dumb Relay
|
||||
The `scripts/build-linux.sh` script handles the full build and deploy
|
||||
lifecycle via Hetzner Cloud VMs.
|
||||
|
||||
The server never sees plaintext message content. It stores and forwards
|
||||
opaque encrypted blobs. Even if the server is fully compromised, an attacker
|
||||
gains:
|
||||
### Key Commands
|
||||
|
||||
- **Encrypted message blobs** (useless without recipient's private keys)
|
||||
- **Public pre-key bundles** (public by design)
|
||||
- **Metadata:** who is messaging whom, when, and how often
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `--ship` | Full pipeline: build on VM, deploy to all production servers, destroy VM |
|
||||
| `--update-all` | Upload pre-built binaries to all production servers and restart |
|
||||
| `--update <user@host>` | Update a single production server |
|
||||
| `--status` | Check service status and federation on all production servers |
|
||||
| `--logs [user@host]` | Tail `journalctl` logs (defaults to first production server) |
|
||||
|
||||
### What the Server CAN See
|
||||
### Typical Deploy Workflow
|
||||
|
||||
| Data | Visible to server |
|
||||
|------|-------------------|
|
||||
| Message plaintext | No |
|
||||
| Sender fingerprint | Yes (in `WireMessage`) |
|
||||
| Recipient fingerprint | Yes (used for routing) |
|
||||
| Message size | Yes |
|
||||
| Timing | Yes |
|
||||
| IP addresses | Yes (from HTTP) |
|
||||
| Pre-key bundles (public keys) | Yes |
|
||||
```bash
|
||||
# One command: build, deploy everywhere, clean up
|
||||
./scripts/build-linux.sh --ship
|
||||
|
||||
### Mitigations for Metadata (Future)
|
||||
|
||||
- **Sealed sender** (Phase 6): hide sender identity from the server.
|
||||
- **Padding:** fixed-size messages to prevent size-based analysis.
|
||||
- **Onion routing** (Phase 6): hide IP addresses via relay chains.
|
||||
|
||||
### Access Control
|
||||
|
||||
The current server has **no authentication**. Anyone can:
|
||||
- Register a key bundle for any fingerprint
|
||||
- Poll messages for any fingerprint
|
||||
- Send messages to any fingerprint
|
||||
|
||||
**TODO (Phase 2):** authentication via Ed25519 challenge-response. Clients
|
||||
sign requests to prove they own the fingerprint they claim.
|
||||
# Or step by step:
|
||||
./scripts/build-linux.sh --all # build (VM persists)
|
||||
./scripts/build-linux.sh --update-all # deploy binaries
|
||||
./scripts/build-linux.sh --destroy # clean up VM
|
||||
./scripts/build-linux.sh --status # verify
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Backup and Recovery
|
||||
## 10. Backup and Recovery
|
||||
|
||||
### Database Backup
|
||||
|
||||
The sled database can be backed up by copying the entire data directory while
|
||||
the server is stopped:
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
systemctl stop warzone-server
|
||||
cp -r /var/lib/warzone /backup/warzone-$(date +%Y%m%d)
|
||||
cp -r /home/warzone/data /backup/warzone-$(date +%Y%m%d)
|
||||
systemctl start warzone-server
|
||||
```
|
||||
|
||||
**Warning:** copying the sled directory while the server is running may
|
||||
produce an inconsistent snapshot. Stop the server first or use filesystem-level
|
||||
snapshots (LVM, ZFS, btrfs).
|
||||
Do not copy the sled directory while the server is running without
|
||||
filesystem-level snapshots.
|
||||
|
||||
### Recovery
|
||||
|
||||
@@ -416,14 +580,5 @@ snapshots (LVM, ZFS, btrfs).
|
||||
2. Replace the data directory with the backup.
|
||||
3. Start the server.
|
||||
|
||||
Messages queued after the backup was taken will be lost. Since all messages
|
||||
are E2E encrypted, there is no way to recover them from any other source.
|
||||
|
||||
### Data Loss Impact
|
||||
|
||||
- **Lost key bundles:** users must re-register. No security impact (public
|
||||
data).
|
||||
- **Lost message queue:** undelivered messages are permanently lost. Senders
|
||||
will not know delivery failed (no delivery receipts yet).
|
||||
- **Corrupted database:** sled includes crash recovery. If the database is
|
||||
corrupt beyond recovery, delete it and start fresh. Users re-register.
|
||||
Messages queued after the backup was taken are permanently lost. All
|
||||
messages are E2E encrypted and cannot be recovered from any other source.
|
||||
|
||||
239
warzone/docs/TASK_PLAN.md
Normal file
239
warzone/docs/TASK_PLAN.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# featherChat Task Plan
|
||||
|
||||
**Version:** 0.0.21+
|
||||
**Last Updated:** 2026-03-28
|
||||
**Naming:** `FC-P{phase}-T{task}[-S{subtask}]`
|
||||
|
||||
---
|
||||
|
||||
## Completed (This Sprint)
|
||||
|
||||
### TUI Refactor
|
||||
- [x] Split `app.rs` monolith (1,756 lines) into 7 modules: types, draw, commands, input, file_transfer, network, mod
|
||||
- [x] 44 unit tests across types.rs, input.rs, draw.rs
|
||||
|
||||
### TUI Improvements
|
||||
- [x] Message timestamps `[HH:MM]` on every ChatLine
|
||||
- [x] Message scrolling (PageUp/Down by 10, Up/Down by 1, auto-snap on send)
|
||||
- [x] Connection status indicator (green/red dot in header)
|
||||
- [x] Unread badge `[N new]` when scrolled up
|
||||
- [x] `/help` command listing all commands + navigation
|
||||
- [x] Terminal bell on incoming DM
|
||||
|
||||
### WZP Server Integration (featherChat side)
|
||||
- [x] FC-2: Call state management (`calls` + `missed_calls` sled trees, `CallState`, `CallStatus`, `active_calls`)
|
||||
- [x] FC-3: WS call signaling awareness (Offer creates CallState, Answer updates, Hangup ends + missed call on offline)
|
||||
- [x] FC-5: Group-to-room mapping (`POST /groups/:name/call` with SHA-256 room ID, fan-out to members)
|
||||
- [x] FC-6: Presence API (`GET /presence/:fp`, `POST /presence/batch`)
|
||||
- [x] FC-7: Missed call notifications (flush on WS reconnect as `{"type":"missed_call"}`)
|
||||
- [x] FC-10: WZP relay config (`GET /wzp/relay-config` + CORS layer)
|
||||
|
||||
### WZP Side (all 9 tasks done by WZP team)
|
||||
- [x] WZP-S-1 through WZP-S-9: Identity alignment, relay auth, signaling bridge, room ACL, crypto handshake, web bridge auth, wzp-proto standalone, CLI seed input, hardcoded assumptions fixed
|
||||
|
||||
---
|
||||
|
||||
## FC-P1: Security & Auth Foundation
|
||||
|
||||
**Goal:** Close the security gaps before wider deployment. Auth enforcement is the critical path.
|
||||
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
| FC-P1-T1 | Auth enforcement middleware | 0.5d | — | TODO |
|
||||
| FC-P1-T2 | Session auto-recovery | 1d | — | TODO |
|
||||
| FC-P1-T3 | Rate limiting + connection guards | 0.5d | — | TODO |
|
||||
| FC-P1-T4 | Device management + session revocation | 1d | T1 | TODO |
|
||||
|
||||
### FC-P1-T1: Auth Enforcement Middleware
|
||||
**What:** Add axum middleware to enforce bearer tokens on protected `/v1/*` routes.
|
||||
**Why:** Currently anyone can impersonate any fingerprint. Tokens are issued but never required.
|
||||
**Scope:**
|
||||
- Extract bearer token from `Authorization` header
|
||||
- Call `validate_token()` for write operations (send, groups, aliases, calls)
|
||||
- Read-only routes (health, key fetch) remain unauthenticated
|
||||
- Return 401 with clear error on invalid/missing token
|
||||
|
||||
### FC-P1-T2: Session Auto-Recovery
|
||||
**What:** When ratchet decryption fails (corrupted state), auto-send a new X3DH KeyExchange.
|
||||
**Why:** Corrupted session = permanent inability to decrypt from that peer.
|
||||
**Scope:**
|
||||
- Detect decryption failure in `process_wire_message()`
|
||||
- Delete corrupted session from local DB
|
||||
- Initiate fresh X3DH key exchange
|
||||
- Show "[session reset]" system message (like Signal)
|
||||
- Cap auto-recovery attempts (max 3 per peer per hour)
|
||||
|
||||
### FC-P1-T3: Rate Limiting + Connection Guards
|
||||
**What:** Tower rate-limit layer + per-fingerprint connection caps.
|
||||
**Why:** Zero protection against auth spam, message flooding, WS connection spam.
|
||||
**Scope:**
|
||||
- Global rate limit: 100 req/s per IP (tower-governor or tower-http)
|
||||
- Per-fingerprint WS connection cap: max 5 simultaneous connections
|
||||
- Auth challenge rate limit: max 10/minute per fingerprint
|
||||
- Group creation limit: max 5/hour per fingerprint
|
||||
|
||||
### FC-P1-T4: Device Management + Session Revocation
|
||||
**What:** Let users see and kill their active sessions.
|
||||
**Why:** Compromised or stale devices need to be revocable immediately.
|
||||
|
||||
| Subtask | What |
|
||||
|---------|------|
|
||||
| FC-P1-T4-S1 | Server: `GET /v1/devices` — list active WS connections (device_id, IP, connected_at) |
|
||||
| FC-P1-T4-S2 | Server: `POST /v1/devices/:id/kick` — force-close WS + invalidate token |
|
||||
| FC-P1-T4-S3 | Server: `POST /v1/devices/revoke-all` — nuke all sessions except current |
|
||||
| FC-P1-T4-S4 | TUI: `/devices` command — list active sessions |
|
||||
| FC-P1-T4-S5 | TUI: `/kick <device_id>` command — revoke a specific device |
|
||||
|
||||
**Dep on T1:** Kick/revoke endpoints must verify the requester owns the fingerprint.
|
||||
|
||||
---
|
||||
|
||||
## FC-P2: TUI Call Integration
|
||||
|
||||
**Goal:** Make call signaling work end-to-end in the TUI. Server infrastructure is ready (FC-2/3/5/6/7).
|
||||
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
| FC-P2-T1 | `/call <fp>` command — send CallSignal::Offer | 0.5d | — | TODO |
|
||||
| FC-P2-T2 | `/accept` + `/reject` commands | 0.5d | T1 | TODO |
|
||||
| FC-P2-T3 | `/hangup` command | 0.25d | T1 | TODO |
|
||||
| FC-P2-T4 | Call state machine (Idle/Ringing/Active/Ended) | 0.5d | T1 | TODO |
|
||||
| FC-P2-T4-S1 | Incoming call notification banner | 0.25d | T4 | TODO |
|
||||
| FC-P2-T4-S2 | In-call header indicator (duration, peer) | 0.25d | T4 | TODO |
|
||||
| FC-P2-T5 | Missed call display (parse WS JSON) | 0.25d | — | TODO |
|
||||
| FC-P2-T6 | `/contacts` online status via presence API | 0.25d | — | TODO |
|
||||
|
||||
---
|
||||
|
||||
## FC-P3: Web Call Integration
|
||||
|
||||
**Goal:** Enable voice/video calling from the browser through featherChat's web client.
|
||||
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
| FC-P3-T1 | WASM: parse CallSignal in `decrypt_wire_message()` | 0.5d | — | TODO |
|
||||
| FC-P3-T2 | WASM: `create_call_signal()` export for JS | 0.5d | — | TODO |
|
||||
| FC-P3-T3 | Web client: call/accept/reject UI | 1d | T1, T2 | TODO |
|
||||
| FC-P3-T4 | Web client: integrate wzp-web audio bridge | 1d | T3 | TODO |
|
||||
| FC-P3-T5 | Extract web client from monolith (web.rs) | 1-2d | — | TODO |
|
||||
|
||||
---
|
||||
|
||||
## FC-P4: Protocol & Architecture
|
||||
|
||||
**Goal:** Harden the protocol for forward compatibility and resilience.
|
||||
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
| FC-P4-T1 | Session state versioning | 0.5d | — | TODO |
|
||||
| FC-P4-T2 | WireMessage versioning (envelope format) | 1d | — | TODO |
|
||||
| FC-P4-T3 | Periodic auto-backup | 0.5d | — | TODO |
|
||||
| FC-P4-T4 | libsignal migration assessment | 1-2w | — | TODO |
|
||||
|
||||
---
|
||||
|
||||
## FC-P5: Major Features
|
||||
|
||||
**Goal:** Core differentiators — physical delivery, federation, identity provider.
|
||||
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
| FC-P5-T1 | Mule binary (physical message delivery) | 3-5d | — | TODO |
|
||||
| FC-P5-T2 | DNS federation (server discovery + relay) | 2-3w | P4-T2 | TODO |
|
||||
| FC-P5-T3 | OIDC identity provider | 1-2w | P1-T1 | TODO |
|
||||
| FC-P5-T4 | Smart contract access control | 3-4w | P5-T3 | TODO |
|
||||
|
||||
---
|
||||
|
||||
## FC-P6: TUI Polish
|
||||
|
||||
**Goal:** UX improvements for daily use.
|
||||
|
||||
| ID | Task | Effort | Dep | Status |
|
||||
|----|------|--------|-----|--------|
|
||||
| FC-P6-T1 | Message search (local history) | 1d | — | TODO |
|
||||
| FC-P6-T2 | Read receipts (viewport tracking) | 0.5d | — | TODO |
|
||||
| FC-P6-T3 | Typing indicators | 0.5d | — | TODO |
|
||||
| FC-P6-T4 | Message reactions (emoji) | 1d | P4-T2 | TODO |
|
||||
| FC-P6-T5 | Voice messages as attachments | 1d | — | TODO |
|
||||
| FC-P6-T6 | Message wrapping for long text | 0.5d | — | TODO |
|
||||
| FC-P6-T7 | Tab completion for commands/aliases | 0.5d | — | TODO |
|
||||
| FC-P6-T8 | File transfer progress gauge | 0.5d | — | TODO |
|
||||
|
||||
---
|
||||
|
||||
## Parallelization Guide
|
||||
|
||||
Tasks with **no dependencies** that can run simultaneously:
|
||||
|
||||
**Sprint A (Security — P1):**
|
||||
```
|
||||
FC-P1-T1 (auth middleware) — server only
|
||||
FC-P1-T2 (session recovery) — client only
|
||||
FC-P1-T3 (rate limiting) — server only
|
||||
→ then FC-P1-T4 (devices, needs T1)
|
||||
```
|
||||
|
||||
**Sprint B (TUI Calls — P2):**
|
||||
```
|
||||
FC-P2-T1 (call command) → T2 (accept/reject) → T3 (hangup)
|
||||
FC-P2-T4 (state machine) → T4-S1 (banner) + T4-S2 (header)
|
||||
FC-P2-T5 (missed calls) — independent
|
||||
FC-P2-T6 (contacts online) — independent
|
||||
```
|
||||
|
||||
**Sprint C (Web — P3):**
|
||||
```
|
||||
FC-P3-T1 (WASM parse) — independent
|
||||
FC-P3-T2 (WASM create) — independent
|
||||
FC-P3-T5 (extract web.rs) — independent
|
||||
→ then T3 (call UI) → T4 (audio)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Architecture (Post-Sprint)
|
||||
|
||||
```
|
||||
warzone-server/src/
|
||||
├── main.rs — startup, CORS, state init
|
||||
├── state.rs — AppState, Connections, CallState, DedupTracker
|
||||
├── db.rs — sled trees: keys, messages, groups, aliases, tokens, calls, missed_calls
|
||||
├── errors.rs — AppError, AppResult
|
||||
├── routes/
|
||||
│ ├── mod.rs — route composition
|
||||
│ ├── auth.rs — challenge-response, token validation
|
||||
│ ├── calls.rs NEW — call CRUD, group call, missed calls API
|
||||
│ ├── presence.rs NEW — online status (single + batch)
|
||||
│ ├── wzp.rs NEW — relay config + service token
|
||||
│ ├── groups.rs — group management + fan-out
|
||||
│ ├── ws.rs — WebSocket handler + call signal awareness + missed call flush
|
||||
│ ├── keys.rs — pre-key bundle registration
|
||||
│ ├── messages.rs — HTTP message queue
|
||||
│ ├── aliases.rs — alias registration + resolution
|
||||
│ ├── health.rs — health check
|
||||
│ └── web.rs — embedded web client
|
||||
```
|
||||
|
||||
## TUI Architecture (Post-Sprint)
|
||||
|
||||
```
|
||||
warzone-client/src/tui/
|
||||
├── mod.rs — run_tui() entry point + event loop
|
||||
├── types.rs — App, ChatLine, PendingFileTransfer, ReceiptStatus, normfp()
|
||||
├── draw.rs — UI rendering (timestamps, scroll, connection dot, unread badge)
|
||||
├── input.rs — keyboard handling (text editing, scroll keys)
|
||||
├── commands.rs — /slash commands + /help
|
||||
├── file_transfer.rs — chunked file send (DM + group)
|
||||
└── network.rs — WS/HTTP polling + incoming message processing + bell
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| Crate | Tests | What |
|
||||
|-------|------:|------|
|
||||
| warzone-protocol | 28 | Crypto, ratchet, X3DH, sender keys, identity, ethereum, prekeys |
|
||||
| warzone-client (types) | 10 | App init, ChatLine, normfp |
|
||||
| warzone-client (input) | 25 | All keybindings, scroll, text editing |
|
||||
| warzone-client (draw) | 9 | Rendering, timestamps, scroll, connection dot, unread badge |
|
||||
| **Total** | **72** | All passing |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Warzone Messenger (featherChat) — Usage Guide
|
||||
# featherChat Usage Guide
|
||||
|
||||
**Version:** 0.0.20
|
||||
**Version:** 0.0.21
|
||||
|
||||
---
|
||||
|
||||
@@ -11,284 +11,184 @@
|
||||
Requirements: Rust 1.75+, cargo
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repo-url>
|
||||
cd warzone
|
||||
|
||||
# Build all binaries
|
||||
cargo build --release
|
||||
|
||||
# Binaries are in target/release/
|
||||
# warzone-server — server
|
||||
# Binaries output to target/release/:
|
||||
# warzone-server — relay server (with embedded web client)
|
||||
# warzone-client — CLI / TUI client
|
||||
```
|
||||
|
||||
### Build the WASM Module (Web Client)
|
||||
### WASM Build (Web Client)
|
||||
|
||||
Requirements: wasm-pack
|
||||
|
||||
```bash
|
||||
cd crates/warzone-wasm
|
||||
wasm-pack build --target web
|
||||
# Output in pkg/ — copy to web client directory
|
||||
# Output in pkg/ — copy to the web client directory
|
||||
```
|
||||
|
||||
### Linux Cross-Compile
|
||||
|
||||
The `scripts/build-linux.sh` script builds Linux x86_64 binaries on a Hetzner Cloud VPS.
|
||||
|
||||
```bash
|
||||
# Full pipeline: create VM, build, download binaries
|
||||
./scripts/build-linux.sh --all
|
||||
|
||||
# Or step by step:
|
||||
./scripts/build-linux.sh --prepare # Create VM, install deps, upload source
|
||||
./scripts/build-linux.sh --build # Build release binaries on the VM
|
||||
./scripts/build-linux.sh --transfer # Download binaries to target/linux-x86_64/
|
||||
./scripts/build-linux.sh --destroy # Delete the VM
|
||||
|
||||
# One-command ship to all production servers:
|
||||
./scripts/build-linux.sh --ship # prepare + build + transfer + deploy + destroy
|
||||
```
|
||||
|
||||
### Server Configuration
|
||||
|
||||
The server accepts two flags:
|
||||
|
||||
```bash
|
||||
warzone-server --bind 0.0.0.0:7700 --data-dir ./warzone-data
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
|--------------|-------------------|-----------------------|
|
||||
|-----------------|------------------|--------------------------------------|
|
||||
| `--bind` | `0.0.0.0:7700` | Listen address |
|
||||
| `--data-dir` | `./warzone-data` | sled database path |
|
||||
| `--enable-bots` | *(off)* | Enable Bot API and BotFather |
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|-------------------------|----------|----------------------------|
|
||||
|--------------------------|---------|------------------------------|
|
||||
| `WARZONE_ADMIN_PASSWORD` | `admin` | Password for admin alias ops |
|
||||
| `RUST_LOG` | `info` | Log level filter |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## Identity
|
||||
|
||||
### CLI Quick Start
|
||||
### Generate a New Identity
|
||||
|
||||
```bash
|
||||
# 1. Generate a new identity
|
||||
warzone init
|
||||
# → Prompts for passphrase
|
||||
# → Displays fingerprint and 24-word mnemonic
|
||||
# → SAVE THE MNEMONIC — it is your identity
|
||||
|
||||
# 2. Register with a server
|
||||
warzone register --server http://your-server:7700
|
||||
|
||||
# 3. Show your identity
|
||||
warzone info
|
||||
# Fingerprint: a3f8:c912:44be:7d01:...
|
||||
# Signing key: ...
|
||||
# Encryption key: ...
|
||||
|
||||
# 4. Send a message
|
||||
warzone send a3f8:c912:44be:7d01:... "Hello!" --server http://your-server:7700
|
||||
|
||||
# 5. Receive messages
|
||||
warzone recv --server http://your-server:7700
|
||||
|
||||
# 6. Launch interactive TUI
|
||||
warzone chat --server http://your-server:7700
|
||||
warzone chat a3f8:c912:44be:7d01:... --server http://your-server:7700
|
||||
warzone chat @alice --server http://your-server:7700
|
||||
```
|
||||
|
||||
### Web Client Quick Start
|
||||
|
||||
1. Navigate to the server URL in a browser (e.g., `http://your-server:7700`)
|
||||
2. The web client generates a new identity automatically on first visit
|
||||
3. Your seed is stored in `localStorage` — back it up via the displayed hex seed
|
||||
4. Use `/peer <fingerprint>` or `/peer @alias` to select a chat partner
|
||||
5. Type messages and press Enter to send
|
||||
|
||||
---
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### `warzone init`
|
||||
|
||||
Generate a new identity (seed + keypair + pre-keys).
|
||||
|
||||
```bash
|
||||
$ warzone init
|
||||
Set passphrase (empty for no encryption): ****
|
||||
Confirm passphrase: ****
|
||||
|
||||
Your identity:
|
||||
Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
Mnemonic: abandon ability able about above absent absorb abstract ...
|
||||
|
||||
SAVE YOUR MNEMONIC — it is the ONLY way to recover your identity.
|
||||
warzone-client init
|
||||
# Prompts for passphrase
|
||||
# Displays fingerprint + 24-word BIP39 mnemonic
|
||||
# SAVE THE MNEMONIC — it is the only way to recover your identity
|
||||
```
|
||||
|
||||
The seed is stored at `~/.warzone/identity.seed`, encrypted with Argon2id + ChaCha20-Poly1305.
|
||||
|
||||
### `warzone recover <words...>`
|
||||
|
||||
Recover an identity from a BIP39 mnemonic.
|
||||
### Recover from Mnemonic
|
||||
|
||||
```bash
|
||||
$ warzone recover abandon ability able about above absent absorb abstract ...
|
||||
Set passphrase (empty for no encryption): ****
|
||||
Confirm passphrase: ****
|
||||
Identity recovered. Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
warzone-client recover abandon ability able about above absent absorb abstract ...
|
||||
# Prompts for passphrase, restores the same identity
|
||||
```
|
||||
|
||||
### `warzone info`
|
||||
|
||||
Display your fingerprint and public keys.
|
||||
### View Your Identity
|
||||
|
||||
```bash
|
||||
$ warzone info
|
||||
Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
Signing key: 3a7b...
|
||||
Encryption key: 9f2c...
|
||||
warzone-client info
|
||||
# Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
```
|
||||
|
||||
### `warzone eth`
|
||||
In the TUI, use `/info` to display your fingerprint, `/seed` to display your 24-word recovery mnemonic, and `/eth` to display your Ethereum address.
|
||||
|
||||
Display your Ethereum-compatible address derived from the same seed.
|
||||
### Ethereum Address
|
||||
|
||||
Your ETH address is derived from the same seed via domain-separated HKDF. One seed, dual identity.
|
||||
|
||||
```bash
|
||||
$ warzone eth
|
||||
Warzone fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
Ethereum address: 0x71C7656EC7ab88b098defB751B7401B5f6d8976F
|
||||
Same seed, dual identity.
|
||||
warzone-client eth
|
||||
# Fingerprint: a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
# Ethereum: 0x71C7656EC7ab88b098defB751B7401B5f6d8976F
|
||||
```
|
||||
|
||||
### `warzone register`
|
||||
### Addressing
|
||||
|
||||
Register your pre-key bundle with a server.
|
||||
featherChat supports three addressing modes. All three work anywhere a peer address is accepted:
|
||||
|
||||
```bash
|
||||
$ warzone register --server http://localhost:7700
|
||||
```
|
||||
|
||||
### `warzone send <recipient> <message>`
|
||||
|
||||
Send an encrypted message. Recipient can be a fingerprint or `@alias`.
|
||||
|
||||
```bash
|
||||
$ warzone send a3f8:c912:44be:7d01:... "Hello!" --server http://localhost:7700
|
||||
$ warzone send @alice "Hello!" --server http://localhost:7700
|
||||
```
|
||||
|
||||
### `warzone recv`
|
||||
|
||||
Poll the server for messages and decrypt them.
|
||||
|
||||
```bash
|
||||
$ warzone recv --server http://localhost:7700
|
||||
```
|
||||
|
||||
### `warzone chat [peer]`
|
||||
|
||||
Launch the interactive TUI client.
|
||||
|
||||
```bash
|
||||
$ warzone chat --server http://localhost:7700
|
||||
$ warzone chat @alice --server http://localhost:7700
|
||||
$ warzone chat a3f8:c912:... --server http://localhost:7700
|
||||
```
|
||||
|
||||
### `warzone backup [output]`
|
||||
|
||||
Export an encrypted backup of local data (sessions, pre-keys).
|
||||
|
||||
```bash
|
||||
$ warzone backup my-backup.wzb
|
||||
Backup saved to my-backup.wzb (4096 bytes encrypted)
|
||||
```
|
||||
|
||||
The backup is encrypted with your seed via HKDF(info="warzone-history") + ChaCha20-Poly1305.
|
||||
|
||||
### `warzone restore <input>`
|
||||
|
||||
Restore from an encrypted backup. Requires the same seed.
|
||||
|
||||
```bash
|
||||
$ warzone restore my-backup.wzb
|
||||
Restored 12 entries from my-backup.wzb
|
||||
```
|
||||
| Format | Example | Description |
|
||||
|--------|---------|-------------|
|
||||
| Fingerprint | `a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4` | SHA-256 of Ed25519 pubkey, 8 groups of 4 hex digits |
|
||||
| Alias | `@alice` | Human-readable, server-resolved |
|
||||
| ETH address | `0x71C7...976F` | Ethereum address derived from the same seed |
|
||||
|
||||
---
|
||||
|
||||
## TUI Commands
|
||||
## TUI Client
|
||||
|
||||
The TUI is launched with `warzone chat`. All commands start with `/`.
|
||||
Launch the interactive terminal UI:
|
||||
|
||||
### Peer & Navigation
|
||||
```bash
|
||||
warzone-client chat --server http://your-server:7700
|
||||
warzone-client chat @alice --server http://your-server:7700
|
||||
warzone-client chat a3f8:c912:... --server http://your-server:7700
|
||||
```
|
||||
|
||||
| Command | Short | Description |
|
||||
|------------------------|-------|----------------------------------------------|
|
||||
| `/peer <fp_or_alias>` | `/p` | Set the active peer (fingerprint or @alias) |
|
||||
| `/dm` | | Switch to DM mode (clear group context) |
|
||||
| `/r` or `/reply` | | Switch to last person who DM'd you |
|
||||
| `/info` | | Display your fingerprint |
|
||||
| `/eth` | | Display your Ethereum address |
|
||||
| `/quit` | `/q` | Exit the TUI |
|
||||
### Complete Command Reference
|
||||
|
||||
#### Peer and Navigation
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/peer <fingerprint>` | Set DM peer by fingerprint |
|
||||
| `/p @alias` | Set DM peer by alias (short form of `/peer`) |
|
||||
| `/peer 0x...` | Set DM peer by ETH address |
|
||||
| `/r [message]` | Reply to last DM sender; optionally include an inline message |
|
||||
| `/dm` | Switch to DM mode (clear group context) |
|
||||
|
||||
```
|
||||
/peer a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
/p @alice
|
||||
/peer 0x71C7656EC7ab88b098defB751B7401B5f6d8976F
|
||||
/r
|
||||
/r hey, got your message
|
||||
/dm
|
||||
```
|
||||
|
||||
### Alias Management
|
||||
#### Groups
|
||||
|
||||
| Command | Description |
|
||||
|-----------------------|--------------------------------------------|
|
||||
| `/alias <name>` | Register an alias for your fingerprint |
|
||||
| `/unalias` | Remove your alias |
|
||||
| `/aliases` | List all registered aliases |
|
||||
|
||||
```
|
||||
/alias alice
|
||||
/unalias
|
||||
/aliases
|
||||
```
|
||||
|
||||
When you register an alias, the server returns a **recovery key** (32 hex chars). Save it — it is the only way to reclaim the alias if you lose access to your identity.
|
||||
|
||||
### Contacts & History
|
||||
|
||||
| Command | Short | Description |
|
||||
|------------------------|-------|------------------------------------------|
|
||||
| `/contacts` | `/c` | List all contacts with message counts |
|
||||
| `/history [peer]` | `/h` | Show message history (last 50 messages) |
|
||||
|
||||
```
|
||||
/contacts
|
||||
/c
|
||||
/history a3f8c91244be7d01
|
||||
/h
|
||||
```
|
||||
|
||||
If a peer is already set, `/h` without arguments shows that peer's history.
|
||||
|
||||
### Group Commands
|
||||
|
||||
| Command | Description |
|
||||
|------------------------|------------------------------------------|
|
||||
| `/g <name>` | Switch to group (auto-join) |
|
||||
| `/gcreate <name>` | Create a new group |
|
||||
|---------|-------------|
|
||||
| `/g <name>` | Switch to group (auto-joins if not a member) |
|
||||
| `/gcreate <name>` | Create a new group (you become creator) |
|
||||
| `/gjoin <name>` | Join an existing group |
|
||||
| `/gleave` | Leave the current group |
|
||||
| `/gkick <fp_or_alias>` | Kick a member (creator only) |
|
||||
| `/gmembers` | List members of the current group |
|
||||
| `/gmembers` | List members of the current group with online status |
|
||||
| `/glist` | List all groups on the server |
|
||||
|
||||
```
|
||||
/gcreate ops-team
|
||||
/g ops-team
|
||||
/gjoin ops-team
|
||||
/gmembers
|
||||
/gkick a3f8c91244be7d01
|
||||
/gkick @mallory
|
||||
/gleave
|
||||
/glist
|
||||
```
|
||||
|
||||
Group messages are prefixed with `#groupname` in the UI. The current target shows in the header bar.
|
||||
When in a group, the header bar shows `#groupname` and all messages are sent to that group.
|
||||
|
||||
### File Transfer
|
||||
#### Alias Management
|
||||
|
||||
| Command | Description |
|
||||
|-------------------|----------------------------------------------|
|
||||
|---------|-------------|
|
||||
| `/alias <name>` | Register an alias for your fingerprint |
|
||||
| `/aliases` | List all registered aliases |
|
||||
| `/unalias` | Remove your alias |
|
||||
|
||||
Alias rules: 1-32 alphanumeric characters (plus `_` and `-`), case-insensitive, normalized to lowercase. TTL is 365 days of inactivity, with a 30-day grace period. Registration returns a recovery key — save it.
|
||||
|
||||
#### File Transfer
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/file <path>` | Send a file to the current peer or group |
|
||||
|
||||
```
|
||||
@@ -296,255 +196,235 @@ Group messages are prefixed with `#groupname` in the UI. The current target show
|
||||
/file ./photo.jpg
|
||||
```
|
||||
|
||||
Constraints:
|
||||
- Maximum file size: 10 MB
|
||||
- Chunk size: 64 KB
|
||||
- Files are sent as `FileHeader` + `FileChunk` wire messages
|
||||
- SHA-256 verification on receipt
|
||||
- Received files are saved to the current directory
|
||||
Files are split into 64 KB chunks, each encrypted with the Double Ratchet session key. The recipient reassembles and verifies a SHA-256 hash over the complete file. Maximum file size is 10 MB. Received files are saved to the current directory.
|
||||
|
||||
### Input Editing
|
||||
#### Contacts and History
|
||||
|
||||
The TUI supports full readline-style editing:
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/contacts` or `/c` | List all contacts with message counts |
|
||||
| `/history` or `/h` | Show message history for current peer (last 50) |
|
||||
| `/history <fp>` | Show history for a specific peer |
|
||||
|
||||
#### Identity and Security
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/info` | Show your fingerprint |
|
||||
| `/eth` | Show your Ethereum address |
|
||||
| `/seed` | Show your 24-word recovery mnemonic |
|
||||
| `/devices` | List your active device sessions |
|
||||
| `/kick <device_id>` | Revoke a specific device session |
|
||||
|
||||
#### Friend List
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/friend` | List friends with online/offline status |
|
||||
| `/friend <address>` | Add a friend by fingerprint or alias |
|
||||
| `/unfriend <address>` | Remove a friend |
|
||||
|
||||
The friend list is end-to-end encrypted and stored on the server as an opaque blob. The server cannot read it. Presence status (online/offline) is shown next to each friend.
|
||||
|
||||
#### General
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help` or `/?` | Show command list |
|
||||
| `/quit` or `/q` | Exit the TUI |
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
| Key | Action |
|
||||
|-----------------|------------------------------|
|
||||
| Left/Right | Move cursor |
|
||||
| Home / Ctrl-A | Move to beginning of line |
|
||||
| End / Ctrl-E | Move to end of line |
|
||||
| Backspace | Delete character before cursor|
|
||||
| Delete | Delete character at cursor |
|
||||
| Ctrl-U | Clear entire input |
|
||||
|-----|--------|
|
||||
| PageUp / PageDown | Scroll messages by 10 |
|
||||
| Up / Down (when input is empty) | Scroll messages by 1 |
|
||||
| Ctrl+End | Snap scroll to bottom |
|
||||
| Left / Right | Move cursor in input |
|
||||
| Home / Ctrl-A | Beginning of line |
|
||||
| End / Ctrl-E | End of line |
|
||||
| Ctrl-U | Clear input |
|
||||
| Ctrl-W | Delete word before cursor |
|
||||
| Enter | Send message / execute command|
|
||||
| Ctrl-C | Quit |
|
||||
|
||||
### Receipt Indicators
|
||||
|
||||
Sent messages display receipt status:
|
||||
|
||||
| Indicator | Meaning |
|
||||
|-----------|----------------------------|
|
||||
| (tick) | Sent (no confirmation yet) |
|
||||
| (double tick, gray) | Delivered (decrypted by recipient) |
|
||||
| (double tick, blue) | Read (viewed by recipient) |
|
||||
|-----------|---------|
|
||||
| Single tick | Sent (no confirmation yet) |
|
||||
| Double tick (gray) | Delivered (decrypted by recipient) |
|
||||
| Double tick (blue) | Read (viewed by recipient) |
|
||||
|
||||
---
|
||||
|
||||
## Web Client Commands
|
||||
## Web Client
|
||||
|
||||
The web client supports the same commands as the TUI, plus additional web-specific commands:
|
||||
### Access
|
||||
|
||||
### Standard Commands (same as TUI)
|
||||
Navigate to the server URL in a browser (e.g., `http://your-server:7700`). The web client generates a new identity automatically on first visit. Your seed is stored in `localStorage` — back it up using `/seed`.
|
||||
|
||||
`/peer`, `/p`, `/alias`, `/unalias`, `/r`, `/reply`, `/contacts`, `/c`,
|
||||
`/history`, `/h`, `/g`, `/gcreate`, `/gjoin`, `/gleave`, `/gkick`, `/gmembers`,
|
||||
`/glist`, `/file`, `/eth`, `/info`, `/quit`, `/dm`
|
||||
The web client uses the same E2E encryption as the TUI, compiled to WASM.
|
||||
|
||||
### Alias Resolution
|
||||
### URL Deep Links
|
||||
|
||||
Both TUI and web support `@alias` syntax:
|
||||
The web client supports deep links for direct navigation:
|
||||
|
||||
```
|
||||
/peer @alice # Resolves alias to fingerprint
|
||||
/p @bob # Short form
|
||||
```
|
||||
| URL | Effect |
|
||||
|-----|--------|
|
||||
| `/message/@alice` | Opens a DM with the alias `@alice` |
|
||||
| `/message/0xABC...` | Opens a DM with an ETH address |
|
||||
| `/group/#ops` | Opens the group `#ops` |
|
||||
|
||||
### Web-Only Commands
|
||||
Share these links to let someone jump straight into a conversation.
|
||||
|
||||
| Command | Description |
|
||||
|-------------------|----------------------------------------------------|
|
||||
| `/selftest` | Run WASM crypto self-test (X3DH + ratchet cycle) |
|
||||
| `/bundleinfo` | Debug: show bundle details (keys, sizes) |
|
||||
| `/debug` | Toggle debug mode (verbose output) |
|
||||
| `/reset` | Clear identity and all local data |
|
||||
| `/install` | Show PWA installation instructions |
|
||||
| `/sessions` | List active ratchet sessions |
|
||||
| `/admin-unalias` | Admin: remove any alias (requires admin password) |
|
||||
### Clickable Addresses
|
||||
|
||||
### Web Client Storage
|
||||
Fingerprints and addresses displayed in messages are clickable. Clicking an address sets it as your DM peer. If you are currently typing, clicking copies the address instead.
|
||||
|
||||
The web client stores data in `localStorage`:
|
||||
### Supported Commands
|
||||
|
||||
| Key | Value | Purpose |
|
||||
|----------------------|--------------------------------|----------------------------|
|
||||
| `wz_seed` | hex seed (64 chars) | Identity seed |
|
||||
| `wz_spk_secret` | hex SPK secret (64 chars) | Signed pre-key secret |
|
||||
| `wz_session:<fp>` | base64 ratchet state | Per-peer session |
|
||||
| `wz_contacts` | JSON contact list | Contact metadata |
|
||||
The web client supports the same slash commands as the TUI: `/peer`, `/p`, `/r`, `/dm`, `/g`, `/gcreate`, `/gjoin`, `/gleave`, `/gkick`, `/gmembers`, `/glist`, `/alias`, `/aliases`, `/unalias`, `/file`, `/contacts`, `/c`, `/history`, `/h`, `/info`, `/eth`, `/seed`, `/friend`, `/unfriend`, `/devices`, `/kick`, `/help`, `/quit`.
|
||||
|
||||
---
|
||||
|
||||
## Identity Management
|
||||
## Groups
|
||||
|
||||
### Seed
|
||||
|
||||
Your identity is a 32-byte seed. All keys are deterministically derived from it. **Lose the seed = lose the identity forever.**
|
||||
|
||||
### Mnemonic Backup
|
||||
|
||||
The seed is displayed as a 24-word BIP39 mnemonic during `warzone init`. Write it down on paper and store securely. You can recover your full identity from the mnemonic using `warzone recover`.
|
||||
|
||||
### Passphrase Encryption
|
||||
|
||||
The seed file (`~/.warzone/identity.seed`) is encrypted at rest:
|
||||
### Creating and Using Groups
|
||||
|
||||
```
|
||||
File format: WZS1(4 bytes) + salt(16) + nonce(12) + ciphertext(48)
|
||||
|
||||
Encryption: Argon2id(passphrase, salt) → 32-byte key
|
||||
ChaCha20-Poly1305(key, nonce, seed) → ciphertext
|
||||
/gcreate ops-team # Create (you become creator)
|
||||
/g ops-team # Switch to group (auto-joins if needed)
|
||||
/gjoin ops-team # Explicitly join an existing group
|
||||
```
|
||||
|
||||
An empty passphrase stores the seed in plaintext (for testing only).
|
||||
|
||||
### Ethereum Address
|
||||
|
||||
Your Ethereum address is derived from the same seed with domain-separated HKDF. Use `warzone eth` or `/eth` in the TUI to display it.
|
||||
|
||||
### Fingerprint Format
|
||||
|
||||
Fingerprints are `SHA-256(Ed25519_pubkey)[:16]` displayed as 8 groups of 4 hex digits:
|
||||
|
||||
```
|
||||
a3f8:c912:44be:7d01:9e5a:3b2c:7f80:12d4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alias System
|
||||
|
||||
Aliases provide human-readable names for fingerprints.
|
||||
|
||||
### Registration
|
||||
|
||||
```
|
||||
/alias alice
|
||||
```
|
||||
|
||||
Returns a **recovery key** — save it securely. One alias per fingerprint. One fingerprint per alias.
|
||||
|
||||
### Rules
|
||||
|
||||
- Aliases are 1-32 alphanumeric characters (plus `_` and `-`)
|
||||
- Case-insensitive, normalized to lowercase
|
||||
- TTL: 365 days of inactivity (auto-renewed on any message activity)
|
||||
- Grace period: 30 days after expiry before reclamation
|
||||
- Recovery key: allows reclaiming an expired alias
|
||||
|
||||
### Recovery
|
||||
|
||||
If you lose access to your identity but have the recovery key, the server provides an alias recovery endpoint. This is an HTTP API operation:
|
||||
|
||||
```
|
||||
POST /v1/alias/recover
|
||||
{
|
||||
"alias": "alice",
|
||||
"recovery_key": "a1b2c3...",
|
||||
"new_fingerprint": "new_fp_hex"
|
||||
}
|
||||
```
|
||||
|
||||
The recovery key is rotated on each recovery.
|
||||
|
||||
### Admin Operations
|
||||
|
||||
An admin (with `WARZONE_ADMIN_PASSWORD`) can remove any alias:
|
||||
|
||||
```
|
||||
POST /v1/alias/admin-remove
|
||||
{
|
||||
"alias": "alice",
|
||||
"admin_password": "admin"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Group Management
|
||||
|
||||
### Creating and Joining
|
||||
|
||||
```
|
||||
/gcreate ops-team # Create a group (you become creator)
|
||||
/g ops-team # Switch to group (auto-joins if not a member)
|
||||
/gjoin ops-team # Explicitly join
|
||||
```
|
||||
|
||||
Groups auto-create on first join if they do not exist.
|
||||
|
||||
### Messaging
|
||||
|
||||
When the peer is set to a group (shows as `#groupname` in the header), all messages go to that group. The server fans out to all members.
|
||||
Once in a group, all messages you type go to that group. The server fans out to all members.
|
||||
|
||||
### Membership
|
||||
|
||||
- Creator can kick members with `/gkick <fingerprint>`
|
||||
- Any member can leave with `/gleave`
|
||||
- `/gmembers` shows all members with their aliases (if registered)
|
||||
- `/gmembers` shows all members with aliases and online status.
|
||||
- The creator can kick members with `/gkick <fingerprint_or_alias>`.
|
||||
- Any member can leave with `/gleave`.
|
||||
|
||||
### Sender Keys (Implemented in Protocol)
|
||||
### Sender Keys
|
||||
|
||||
The protocol implements Sender Keys for efficient group encryption:
|
||||
|
||||
1. Each member generates a `SenderKey` (random 32-byte chain key)
|
||||
2. The key is distributed to all members via 1:1 encrypted channels (`SenderKeyDistribution`)
|
||||
3. Group messages are encrypted once with the sender's key (`GroupSenderKey`)
|
||||
4. On member join/leave, all members rotate their sender keys
|
||||
|
||||
This provides O(1) encryption per message instead of O(N) per-member encryption.
|
||||
The protocol uses Sender Keys for efficient group encryption. Each member generates a random 32-byte chain key, distributes it to all other members over 1:1 encrypted channels, and encrypts group messages with their sender key. This gives O(1) encryption cost per message instead of O(N). Sender keys are rotated on member join or leave.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Device Setup
|
||||
## File Transfer
|
||||
|
||||
### Current Support
|
||||
Files are transferred end-to-end encrypted through the relay server.
|
||||
|
||||
The server stores per-device bundles (`device:<fp>:<device_id>`). Multiple WebSocket connections per fingerprint are supported — all connected devices receive messages.
|
||||
1. The sender reads the file and splits it into 64 KB chunks.
|
||||
2. A `FileHeader` message is sent with the filename, total size, chunk count, and SHA-256 hash.
|
||||
3. Each `FileChunk` is encrypted with the Double Ratchet session and sent sequentially.
|
||||
4. The recipient reassembles all chunks and verifies the SHA-256 hash.
|
||||
5. The completed file is saved to the current directory.
|
||||
|
||||
### Setting Up a Second Device
|
||||
|
||||
1. On the new device, recover from mnemonic: `warzone recover <24 words>`
|
||||
2. Register with the server: `warzone register --server http://...`
|
||||
3. Both devices now share the same fingerprint and receive messages
|
||||
|
||||
### Limitations
|
||||
|
||||
- Ratchet sessions are per-device (not synchronized between devices)
|
||||
- Starting a new session on one device does not invalidate the other's session
|
||||
- Encrypted backup/restore can transfer session state between devices
|
||||
Maximum file size: **10 MB**. Chunk size: **64 KB**.
|
||||
|
||||
---
|
||||
|
||||
## Encrypted Backup & Restore
|
||||
## Friend List
|
||||
|
||||
### Creating a Backup
|
||||
The friend list provides presence tracking for contacts you care about.
|
||||
|
||||
- `/friend <address>` adds a friend (by fingerprint or alias).
|
||||
- `/friend` lists all friends with their current online/offline status.
|
||||
- `/unfriend <address>` removes a friend.
|
||||
|
||||
The friend list is encrypted client-side and stored on the server as an opaque blob. The server relays it but cannot read its contents.
|
||||
|
||||
---
|
||||
|
||||
## Bots
|
||||
|
||||
### Messaging Bots
|
||||
|
||||
Clients auto-detect bot aliases (names ending in `Bot`, `bot`, or `_bot`) and send messages as plaintext -- no E2E session is established. Simply use `/peer @mybot_bot` and type your message. The client handles the rest.
|
||||
|
||||
### Creating Bots with BotFather
|
||||
|
||||
To create a bot, message `@botfather`:
|
||||
|
||||
1. `/peer @botfather`
|
||||
2. Send a message requesting a new bot (e.g., "create WeatherBot")
|
||||
3. BotFather registers the bot and returns the API token
|
||||
4. Use the token to run your bot against the server's Bot API
|
||||
|
||||
BotFather is auto-created on servers that have `--enable-bots` enabled. It is the only way to create bots.
|
||||
|
||||
### Bot Bridge for Telegram Compatibility
|
||||
|
||||
The `tools/bot-bridge.py` script provides a compatibility layer that lets you use existing Telegram bot libraries (python-telegram-bot, aiogram, Telegraf) with featherChat. It translates between the two APIs, handling differences like fingerprint-based addressing and numeric ID translation.
|
||||
|
||||
```bash
|
||||
warzone backup my-backup.wzb
|
||||
python tools/bot-bridge.py --token YOUR_BOT_TOKEN --server http://localhost:7700
|
||||
```
|
||||
|
||||
This exports:
|
||||
- All ratchet sessions (Double Ratchet state)
|
||||
- All pre-key secrets (signed + one-time)
|
||||
- Encrypted with HKDF(seed, info="warzone-history") + ChaCha20-Poly1305
|
||||
See `docs/BOT_API.md` and `docs/LLM_BOT_DEV.md` for full Bot API documentation.
|
||||
|
||||
### Restoring a Backup
|
||||
---
|
||||
|
||||
```bash
|
||||
warzone restore my-backup.wzb
|
||||
```
|
||||
## Federation
|
||||
|
||||
Requires the same seed (passphrase prompt). Merges data without overwriting existing entries.
|
||||
Federation connects two featherChat servers so that users on different servers can message each other transparently.
|
||||
|
||||
### Backup File Format
|
||||
### Setup
|
||||
|
||||
```
|
||||
WZH1(4 bytes) + nonce(12) + ciphertext
|
||||
Each server needs a federation config file:
|
||||
|
||||
Plaintext: JSON {
|
||||
"version": 1,
|
||||
"sessions": { "<fp>": "base64_bincode", ... },
|
||||
"pre_keys": { "spk:1": "base64_bytes", "otpk:1": "base64_bytes", ... }
|
||||
```json
|
||||
{
|
||||
"server_id": "alpha",
|
||||
"shared_secret": "long-random-string-shared-between-both-servers",
|
||||
"peer": {
|
||||
"id": "bravo",
|
||||
"url": "http://10.0.0.2:7700"
|
||||
},
|
||||
"presence_interval_secs": 5
|
||||
}
|
||||
```
|
||||
|
||||
Start the server with federation enabled:
|
||||
|
||||
```bash
|
||||
warzone-server --bind 0.0.0.0:7700 --federation federation.json
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
The two servers maintain a persistent WebSocket connection between them. When a client on server Alpha sends a message to a fingerprint registered on server Bravo, server Alpha forwards the message over the federation link. The recipient's server delivers it via their normal WebSocket connection. Presence information is exchanged on a configurable interval.
|
||||
|
||||
From the user's perspective, federation is transparent. You address peers the same way regardless of which server they are on.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Device
|
||||
|
||||
### Setup
|
||||
|
||||
1. On the new device, recover from mnemonic: `warzone-client recover <24 words>`
|
||||
2. Register with the server: `warzone-client register --server http://...`
|
||||
3. Both devices share the same fingerprint and receive messages.
|
||||
|
||||
### Device Management
|
||||
|
||||
- `/devices` lists all active sessions for your identity.
|
||||
- `/kick <device_id>` revokes a specific device session.
|
||||
|
||||
Ratchet sessions are per-device and not synchronized between devices. Use encrypted backup/restore (`warzone-client backup` / `warzone-client restore`) to transfer session state.
|
||||
|
||||
---
|
||||
|
||||
## Encrypted Backup and Restore
|
||||
|
||||
```bash
|
||||
# Export sessions and pre-keys, encrypted with your seed
|
||||
warzone-client backup my-backup.wzb
|
||||
|
||||
# Restore on another device (requires same seed)
|
||||
warzone-client restore my-backup.wzb
|
||||
```
|
||||
|
||||
The backup contains all Double Ratchet sessions and pre-key secrets. It is encrypted with HKDF(seed, info="warzone-history") + ChaCha20-Poly1305.
|
||||
|
||||
9
warzone/federation.example.json
Normal file
9
warzone/federation.example.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"server_id": "alpha",
|
||||
"shared_secret": "change-me-to-a-long-random-string-shared-between-both-servers",
|
||||
"peer": {
|
||||
"id": "bravo",
|
||||
"url": "http://10.0.0.2:7700"
|
||||
},
|
||||
"presence_interval_secs": 5
|
||||
}
|
||||
283
warzone/scripts/build-bleeding.sh
Executable file
283
warzone/scripts/build-bleeding.sh
Executable file
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build featherChat Linux x86_64 bleeding-edge binaries on Hetzner Cloud.
|
||||
# Uses latest Fedora VM + Arch Linux Docker container for the actual build.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-bleeding.sh --all Create VM + build + download
|
||||
# ./scripts/build-bleeding.sh --ship Build + deploy to all servers + destroy
|
||||
# ./scripts/build-bleeding.sh --prepare Create VM only
|
||||
# ./scripts/build-bleeding.sh --build Build in Arch Docker container
|
||||
# ./scripts/build-bleeding.sh --transfer Download binaries
|
||||
# ./scripts/build-bleeding.sh --destroy Delete VM
|
||||
|
||||
VM_NAME="fc-bleeding"
|
||||
SSH_KEY_NAME="wz"
|
||||
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
|
||||
SERVER_TYPE="cx33"
|
||||
IMAGE="fedora-43"
|
||||
REMOTE_USER="root"
|
||||
OUTPUT_DIR="target/linux-x86_64-bleeding"
|
||||
PROJECT_DIR="/Users/manwe/CascadeProjects/featherChat/warzone"
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10"
|
||||
BINS="warzone-server warzone-client"
|
||||
|
||||
# Production servers (shared with build-linux.sh)
|
||||
PROD_SERVERS=("root@mequ" "root@kh3rad3ree")
|
||||
PROD_SERVICE="warzone-server"
|
||||
PROD_BIN_DIR="/home/warzone"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
get_vm_ip() {
|
||||
local ip
|
||||
ip=$(hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | tr -d ' ')
|
||||
if [ -z "$ip" ]; then
|
||||
echo "ERROR: No VM '$VM_NAME' found. Run --prepare first." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$ip"
|
||||
}
|
||||
|
||||
ssh_cmd() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --prepare: Create Fedora VM, install Docker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_prepare() {
|
||||
local existing
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
|
||||
if [ -n "$existing" ]; then
|
||||
echo "VM already exists: $existing"
|
||||
echo "Reusing it. Uploading fresh source..."
|
||||
do_upload
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[1/5] Creating Hetzner VM: $VM_NAME ($SERVER_TYPE, $IMAGE)..."
|
||||
hcloud server create \
|
||||
--name "$VM_NAME" \
|
||||
--type "$SERVER_TYPE" \
|
||||
--image "$IMAGE" \
|
||||
--ssh-key "$SSH_KEY_NAME" \
|
||||
--location fsn1 \
|
||||
--quiet
|
||||
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
echo " VM: $VM_NAME @ $ip"
|
||||
|
||||
echo "[2/5] Waiting for SSH..."
|
||||
for i in $(seq 1 30); do
|
||||
if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "echo ok" &>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "[3/5] Installing Docker on Fedora..."
|
||||
ssh_cmd "dnf install -y -q docker > /dev/null 2>&1 && systemctl start docker && systemctl enable docker" 2>/dev/null
|
||||
|
||||
echo "[4/5] Pulling Arch Linux Docker image..."
|
||||
ssh_cmd "docker pull archlinux:latest" 2>/dev/null
|
||||
|
||||
echo "[5/5] Uploading source..."
|
||||
do_upload
|
||||
|
||||
echo ""
|
||||
echo "=== VM Ready ==="
|
||||
echo "IP: $ip"
|
||||
echo "Next: ./scripts/build-bleeding.sh --build"
|
||||
}
|
||||
|
||||
do_upload() {
|
||||
echo " Creating source tarball..."
|
||||
tar czf /tmp/fc-bleeding-src.tar.gz \
|
||||
--exclude='target' \
|
||||
--exclude='.git' \
|
||||
--exclude='.claude' \
|
||||
--exclude='warzone-phone' \
|
||||
--exclude='notes' \
|
||||
-C "$PROJECT_DIR" . 2>/dev/null
|
||||
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
local size
|
||||
size=$(du -h /tmp/fc-bleeding-src.tar.gz | cut -f1)
|
||||
echo " Uploading $size to VM..."
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" /tmp/fc-bleeding-src.tar.gz "$REMOTE_USER@$ip:/root/fc-bleeding-src.tar.gz" 2>/dev/null
|
||||
ssh_cmd "rm -rf /root/featherChat && mkdir -p /root/featherChat && tar xzf /root/fc-bleeding-src.tar.gz -C /root/featherChat" 2>/dev/null
|
||||
rm -f /tmp/fc-bleeding-src.tar.gz
|
||||
echo " Source uploaded."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --build: Build inside Arch Linux Docker container
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_build() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
echo "=== Bleeding Edge Build on $ip ==="
|
||||
echo " Host: Fedora ($IMAGE)"
|
||||
echo " Build: Arch Linux (Docker, latest)"
|
||||
echo ""
|
||||
|
||||
echo "[1/3] Building in Arch Linux container..."
|
||||
ssh_cmd "docker run --rm -v /root/featherChat:/build -w /build archlinux:latest bash -c '
|
||||
# Install deps
|
||||
pacman -Sy --noconfirm base-devel pkg-config openssl rustup wasm-pack > /dev/null 2>&1
|
||||
rustup default stable 2>/dev/null
|
||||
rustup target add wasm32-unknown-unknown 2>/dev/null
|
||||
|
||||
# Build WASM
|
||||
echo \"[wasm] Building...\"
|
||||
wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg 2>&1 | tail -3
|
||||
|
||||
# Build release binaries
|
||||
echo \"[cargo] Building release...\"
|
||||
cargo build --release --bin warzone-server --bin warzone-client 2>&1
|
||||
'"
|
||||
|
||||
echo ""
|
||||
echo "[2/3] Verifying binaries..."
|
||||
ssh_cmd "ls -lh /root/featherChat/target/release/warzone-server /root/featherChat/target/release/warzone-client"
|
||||
|
||||
echo ""
|
||||
echo "[3/3] Checking linked libraries..."
|
||||
ssh_cmd "docker run --rm -v /root/featherChat:/build archlinux:latest ldd /build/target/release/warzone-server | head -10"
|
||||
|
||||
echo ""
|
||||
echo "=== Build Complete ==="
|
||||
echo "Next: ./scripts/build-bleeding.sh --transfer"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --transfer: Download binaries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_transfer() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
echo "=== Downloading bleeding-edge binaries from $ip ==="
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
for bin in $BINS; do
|
||||
echo " $bin..."
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:/root/featherChat/target/release/$bin" "$OUTPUT_DIR/$bin" 2>/dev/null
|
||||
done
|
||||
|
||||
# Copy federation example
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:/root/featherChat/federation.example.json" "$OUTPUT_DIR/" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Transfer Complete ==="
|
||||
ls -lh "$OUTPUT_DIR"/warzone-*
|
||||
echo ""
|
||||
echo "Built with: Arch Linux latest (bleeding edge)"
|
||||
echo "Deploy: scp $OUTPUT_DIR/warzone-server $OUTPUT_DIR/warzone-client user@server:~/warzone/"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --destroy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_destroy() {
|
||||
local existing
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
|
||||
if [ -z "$existing" ]; then
|
||||
echo "No VM '$VM_NAME' to destroy."
|
||||
return
|
||||
fi
|
||||
echo "Deleting VM: $VM_NAME"
|
||||
hcloud server delete "$VM_NAME"
|
||||
echo "Done."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --update-all / --ship
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_update() {
|
||||
local host="$1"
|
||||
echo "=== Updating $host (bleeding) ==="
|
||||
ssh "$host" "systemctl stop $PROD_SERVICE 2>/dev/null || true"
|
||||
scp "$OUTPUT_DIR/warzone-server" "$OUTPUT_DIR/warzone-client" "$host:$PROD_BIN_DIR/"
|
||||
ssh "$host" "chmod +x $PROD_BIN_DIR/warzone-server $PROD_BIN_DIR/warzone-client && systemctl start $PROD_SERVICE"
|
||||
sleep 1
|
||||
local status
|
||||
status=$(ssh "$host" "systemctl is-active $PROD_SERVICE 2>/dev/null" || true)
|
||||
echo " $host: $status"
|
||||
}
|
||||
|
||||
do_update_all() {
|
||||
for host in "${PROD_SERVERS[@]}"; do
|
||||
do_update "$host"
|
||||
done
|
||||
}
|
||||
|
||||
do_ship() {
|
||||
echo "========================================"
|
||||
echo " SHIPPING (bleeding edge) to production"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
do_prepare
|
||||
echo ""
|
||||
do_build
|
||||
echo ""
|
||||
do_transfer
|
||||
echo ""
|
||||
do_update_all
|
||||
echo ""
|
||||
do_destroy
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " BLEEDING EDGE SHIP COMPLETE"
|
||||
echo "========================================"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
case "${1:-}" in
|
||||
--prepare) do_prepare ;;
|
||||
--build) do_build ;;
|
||||
--transfer) do_transfer ;;
|
||||
--destroy) do_destroy ;;
|
||||
--upload) do_upload ;;
|
||||
--all)
|
||||
do_prepare
|
||||
do_build
|
||||
do_transfer
|
||||
echo "VM still running. Destroy: ./scripts/build-bleeding.sh --destroy"
|
||||
;;
|
||||
--ship) do_ship ;;
|
||||
--update-all) do_update_all ;;
|
||||
*)
|
||||
echo "Usage: $0 <command>"
|
||||
echo ""
|
||||
echo " --all Create Fedora VM + build in Arch Docker + download"
|
||||
echo " --ship Build + deploy to all servers + destroy VM"
|
||||
echo " --prepare Create VM, install Docker, upload source"
|
||||
echo " --build Build in Arch Linux Docker container"
|
||||
echo " --transfer Download binaries to $OUTPUT_DIR"
|
||||
echo " --destroy Delete the VM"
|
||||
echo " --upload Re-upload source to existing VM"
|
||||
echo " --update-all Deploy bleeding binaries to all servers"
|
||||
echo ""
|
||||
echo "Output: $OUTPUT_DIR/warzone-{server,client}"
|
||||
echo "Host VM: Fedora latest | Build: Arch Linux latest (Docker)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
608
warzone/scripts/build-linux.sh
Executable file
608
warzone/scripts/build-linux.sh
Executable file
@@ -0,0 +1,608 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build featherChat Linux x86_64 release binaries using a Hetzner Cloud VPS.
|
||||
# Prerequisites: hcloud CLI authenticated, SSH key "wz" registered.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-linux.sh --prepare Create VM, install deps, upload source
|
||||
# ./scripts/build-linux.sh --build Build release binaries on the VM
|
||||
# ./scripts/build-linux.sh --transfer Download binaries from VM to local
|
||||
# ./scripts/build-linux.sh --destroy Delete the VM
|
||||
# ./scripts/build-linux.sh --all Run prepare + build + transfer (no destroy)
|
||||
# ./scripts/build-linux.sh --upload Re-upload source to existing VM
|
||||
#
|
||||
# The VM persists between steps so you can iterate on build errors.
|
||||
# Reuses the same WZP builder VM if it already exists.
|
||||
|
||||
VM_NAME="fc-builder"
|
||||
SSH_KEY_NAME="wz"
|
||||
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
|
||||
SERVER_TYPE="cx33"
|
||||
IMAGE="debian-12"
|
||||
REMOTE_USER="root"
|
||||
OUTPUT_DIR="target/linux-x86_64"
|
||||
PROJECT_DIR="/Users/manwe/CascadeProjects/featherChat/warzone"
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10"
|
||||
|
||||
# Binaries to build
|
||||
BINS="warzone-server warzone-client"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
get_vm_ip() {
|
||||
local ip
|
||||
ip=$(hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | tr -d ' ')
|
||||
if [ -z "$ip" ]; then
|
||||
echo "ERROR: No VM '$VM_NAME' found. Run --prepare first." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$ip"
|
||||
}
|
||||
|
||||
ssh_cmd() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
|
||||
}
|
||||
|
||||
scp_to() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$@" "$REMOTE_USER@$ip:/root/" 2>/dev/null
|
||||
}
|
||||
|
||||
scp_from() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
# args: remote_path local_path
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:$1" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --prepare: Create VM, install deps, upload source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_prepare() {
|
||||
# Check if VM already exists
|
||||
local existing
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
|
||||
if [ -n "$existing" ]; then
|
||||
echo "VM already exists: $existing"
|
||||
echo "Reusing it. Uploading fresh source..."
|
||||
do_upload
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[1/5] Creating Hetzner VM: $VM_NAME ($SERVER_TYPE, $IMAGE)..."
|
||||
hcloud server create \
|
||||
--name "$VM_NAME" \
|
||||
--type "$SERVER_TYPE" \
|
||||
--image "$IMAGE" \
|
||||
--ssh-key "$SSH_KEY_NAME" \
|
||||
--location fsn1 \
|
||||
--quiet
|
||||
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
echo " VM: $VM_NAME @ $ip"
|
||||
|
||||
# Wait for SSH
|
||||
echo "[2/5] Waiting for SSH..."
|
||||
for i in $(seq 1 30); do
|
||||
if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "echo ok" &>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Install build dependencies
|
||||
echo "[3/5] Installing build dependencies..."
|
||||
ssh_cmd "apt-get update -qq && apt-get install -y -qq \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
curl \
|
||||
git \
|
||||
> /dev/null 2>&1"
|
||||
|
||||
# Install Rust + wasm-pack
|
||||
echo "[4/5] Installing Rust + wasm-pack..."
|
||||
ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1"
|
||||
ssh_cmd "source ~/.cargo/env && rustup target add wasm32-unknown-unknown > /dev/null 2>&1"
|
||||
ssh_cmd "source ~/.cargo/env && cargo install wasm-pack > /dev/null 2>&1 || true"
|
||||
|
||||
# Upload source
|
||||
echo "[5/5] Uploading source code..."
|
||||
do_upload
|
||||
|
||||
echo ""
|
||||
echo "=== VM Ready ==="
|
||||
echo "IP: $ip"
|
||||
echo "SSH: ssh -i $SSH_KEY_PATH root@$ip"
|
||||
echo ""
|
||||
echo "Next: ./scripts/build-linux.sh --build"
|
||||
}
|
||||
|
||||
do_upload() {
|
||||
echo " Creating source tarball..."
|
||||
|
||||
# Create tarball excluding build artifacts and non-essential files
|
||||
tar czf /tmp/fc-src.tar.gz \
|
||||
--exclude='target' \
|
||||
--exclude='.git' \
|
||||
--exclude='.claude' \
|
||||
--exclude='warzone-phone' \
|
||||
--exclude='notes' \
|
||||
-C "$PROJECT_DIR" . 2>/dev/null
|
||||
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
local size
|
||||
size=$(du -h /tmp/fc-src.tar.gz | cut -f1)
|
||||
echo " Uploading $size to VM..."
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" /tmp/fc-src.tar.gz "$REMOTE_USER@$ip:/root/fc-src.tar.gz" 2>/dev/null
|
||||
ssh_cmd "rm -rf /root/featherChat && mkdir -p /root/featherChat && tar xzf /root/fc-src.tar.gz -C /root/featherChat" 2>/dev/null
|
||||
rm -f /tmp/fc-src.tar.gz
|
||||
echo " Source uploaded."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --build: Build release binaries on the VM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_build() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
echo "=== Building on $ip ==="
|
||||
|
||||
local bin_args=""
|
||||
for bin in $BINS; do
|
||||
bin_args="$bin_args --bin $bin"
|
||||
done
|
||||
|
||||
echo "[1/3] Building WASM (warzone-wasm)..."
|
||||
ssh_cmd "source ~/.cargo/env && cd /root/featherChat && wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg 2>&1" | tail -5
|
||||
|
||||
echo ""
|
||||
echo "[2/3] Building: $BINS"
|
||||
ssh_cmd "source ~/.cargo/env && cd /root/featherChat && cargo build --release $bin_args 2>&1"
|
||||
|
||||
echo ""
|
||||
echo "[3/3] Verifying binaries..."
|
||||
for bin in $BINS; do
|
||||
ssh_cmd "ls -lh /root/featherChat/target/release/$bin" 2>/dev/null
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Build Complete ==="
|
||||
echo "Next: ./scripts/build-linux.sh --transfer"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --transfer: Download binaries from VM to local
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_transfer() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
echo "=== Downloading binaries from $ip ==="
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
for bin in $BINS; do
|
||||
echo " $bin..."
|
||||
scp_from "/root/featherChat/target/release/$bin" "$OUTPUT_DIR/$bin"
|
||||
done
|
||||
|
||||
# Also grab the embedded web client HTML if it exists
|
||||
if ssh_cmd "test -f /root/featherChat/target/release/warzone-server" 2>/dev/null; then
|
||||
echo " federation.example.json..."
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:/root/featherChat/federation.example.json" "$OUTPUT_DIR/federation.example.json" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Transfer Complete ==="
|
||||
ls -lh "$OUTPUT_DIR"/warzone-*
|
||||
echo ""
|
||||
echo "Deploy with:"
|
||||
echo " scp $OUTPUT_DIR/warzone-server $OUTPUT_DIR/warzone-client user@mequ:~/warzone/"
|
||||
echo ""
|
||||
echo "Run on server:"
|
||||
echo " ./warzone-server --bind 0.0.0.0:7700"
|
||||
echo " ./warzone-server --bind 0.0.0.0:7700 --federation federation.json"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --destroy: Delete the VM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_destroy() {
|
||||
local existing
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
|
||||
if [ -z "$existing" ]; then
|
||||
echo "No VM '$VM_NAME' to destroy."
|
||||
return
|
||||
fi
|
||||
echo "Deleting VM: $VM_NAME"
|
||||
hcloud server delete "$VM_NAME"
|
||||
echo "Done."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --deploy: Transfer + deploy to production server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_deploy() {
|
||||
local deploy_host="${2:-}"
|
||||
if [ -z "$deploy_host" ]; then
|
||||
echo "Usage: $0 --deploy <user@host> [--federation <config.json>]"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " $0 --deploy root@mequ.example.com"
|
||||
echo " $0 --deploy root@mequ.example.com --federation federation.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Deploying to $deploy_host ==="
|
||||
|
||||
# Ensure binaries exist locally
|
||||
if [ ! -f "$OUTPUT_DIR/warzone-server" ]; then
|
||||
echo "ERROR: No binaries in $OUTPUT_DIR. Run --build and --transfer first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/3] Uploading binaries..."
|
||||
scp "$OUTPUT_DIR/warzone-server" "$OUTPUT_DIR/warzone-client" "$deploy_host:~/warzone/"
|
||||
|
||||
# Upload federation config if specified
|
||||
local fed_arg=""
|
||||
if [ "${3:-}" = "--federation" ] && [ -n "${4:-}" ]; then
|
||||
echo "[2/3] Uploading federation config..."
|
||||
scp "$4" "$deploy_host:~/warzone/federation.json"
|
||||
fed_arg="--federation ~/warzone/federation.json"
|
||||
else
|
||||
echo "[2/3] No federation config (standalone mode)"
|
||||
fi
|
||||
|
||||
echo "[3/3] Restarting server..."
|
||||
ssh "$deploy_host" "pkill warzone-server || true; sleep 1; cd ~/warzone && nohup ./warzone-server --bind 0.0.0.0:7700 $fed_arg > server.log 2>&1 &"
|
||||
|
||||
echo ""
|
||||
echo "=== Deployed ==="
|
||||
echo "Server running at $deploy_host:7700"
|
||||
echo "Logs: ssh $deploy_host 'tail -f ~/warzone/server.log'"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Production servers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROD_SERVERS=(
|
||||
"root@mequ"
|
||||
"root@kh3rad3ree"
|
||||
)
|
||||
PROD_SERVICE="warzone-server"
|
||||
PROD_BIN_DIR="/home/warzone"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --update <host>: Stop service, upload binaries, restart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_update() {
|
||||
local host="${1:-}"
|
||||
if [ -z "$host" ]; then
|
||||
echo "Usage: $0 --update <user@host>"
|
||||
echo " or: $0 --update-all"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$OUTPUT_DIR/warzone-server" ]; then
|
||||
echo "ERROR: No binaries in $OUTPUT_DIR. Run --all first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Updating $host ==="
|
||||
|
||||
echo "[1/4] Stopping service..."
|
||||
ssh "$host" "systemctl stop $PROD_SERVICE 2>/dev/null || true"
|
||||
|
||||
echo "[2/4] Uploading binaries..."
|
||||
scp "$OUTPUT_DIR/warzone-server" "$OUTPUT_DIR/warzone-client" "$host:$PROD_BIN_DIR/"
|
||||
ssh "$host" "chmod +x $PROD_BIN_DIR/warzone-server $PROD_BIN_DIR/warzone-client"
|
||||
|
||||
echo "[3/4] Starting service..."
|
||||
ssh "$host" "systemctl start $PROD_SERVICE"
|
||||
|
||||
echo "[4/4] Verifying..."
|
||||
sleep 1
|
||||
local status
|
||||
status=$(ssh "$host" "systemctl is-active $PROD_SERVICE 2>/dev/null" || true)
|
||||
if [ "$status" = "active" ]; then
|
||||
echo " $host: $PROD_SERVICE is running"
|
||||
else
|
||||
echo " WARNING: $host: $PROD_SERVICE status = $status"
|
||||
echo " Check logs: ssh $host 'journalctl -u $PROD_SERVICE -n 20'"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --update-all: Update all production servers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_update_all() {
|
||||
if [ ! -f "$OUTPUT_DIR/warzone-server" ]; then
|
||||
echo "ERROR: No binaries in $OUTPUT_DIR. Run --all first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Updating all production servers ==="
|
||||
echo ""
|
||||
for host in "${PROD_SERVERS[@]}"; do
|
||||
do_update "$host"
|
||||
done
|
||||
echo "=== All servers updated ==="
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --status: Check service status on all production servers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_status() {
|
||||
echo "=== Production server status ==="
|
||||
for host in "${PROD_SERVERS[@]}"; do
|
||||
local status
|
||||
status=$(ssh "$host" "systemctl is-active $PROD_SERVICE 2>/dev/null" || echo "unreachable")
|
||||
local uptime
|
||||
uptime=$(ssh "$host" "systemctl show $PROD_SERVICE --property=ActiveEnterTimestamp --value 2>/dev/null" || echo "?")
|
||||
printf " %-20s %s (since %s)\n" "$host" "$status" "$uptime"
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Check federation
|
||||
for host in "${PROD_SERVERS[@]}"; do
|
||||
local addr
|
||||
addr=$(echo "$host" | cut -d@ -f2)
|
||||
echo " Federation ($addr):"
|
||||
curl -s "http://$addr:7700/v1/federation/status" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (unreachable)"
|
||||
echo ""
|
||||
done
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --logs <host>: Tail logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_logs() {
|
||||
local host="${1:-${PROD_SERVERS[0]}}"
|
||||
echo "=== Logs from $host ==="
|
||||
ssh "$host" "journalctl -u $PROD_SERVICE -f --no-pager"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --local: Build locally on this machine (auto-detect package manager)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
detect_pkg_manager() {
|
||||
if command -v apt-get &>/dev/null; then echo "apt"
|
||||
elif command -v dnf &>/dev/null; then echo "dnf"
|
||||
elif command -v pacman &>/dev/null; then echo "pacman"
|
||||
elif command -v brew &>/dev/null; then echo "brew"
|
||||
else echo "unknown"; fi
|
||||
}
|
||||
|
||||
do_local_deps() {
|
||||
local pm
|
||||
pm=$(detect_pkg_manager)
|
||||
echo "[1/4] Installing dependencies ($pm)..."
|
||||
|
||||
case "$pm" in
|
||||
apt)
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq build-essential pkg-config libssl-dev curl >/dev/null 2>&1
|
||||
;;
|
||||
dnf)
|
||||
sudo dnf install -y gcc gcc-c++ make pkg-config openssl-devel curl >/dev/null 2>&1
|
||||
;;
|
||||
pacman)
|
||||
sudo pacman -Sy --noconfirm base-devel pkg-config openssl curl rustup >/dev/null 2>&1
|
||||
# Arch: ensure rustup manages the toolchain (pacman rust conflicts with rustup)
|
||||
if ! rustup show active-toolchain &>/dev/null; then
|
||||
rustup default stable 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
brew)
|
||||
brew install openssl pkg-config 2>/dev/null || true
|
||||
;;
|
||||
*)
|
||||
echo "WARNING: Unknown package manager. Ensure build-essential, pkg-config, libssl-dev are installed."
|
||||
;;
|
||||
esac
|
||||
|
||||
# Ensure Rust is installed
|
||||
if ! command -v cargo &>/dev/null; then
|
||||
echo " Installing Rust..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
source "$HOME/.cargo/env"
|
||||
fi
|
||||
|
||||
# Ensure wasm-pack is installed
|
||||
if ! command -v wasm-pack &>/dev/null; then
|
||||
echo " Installing wasm-pack..."
|
||||
cargo install wasm-pack 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Ensure wasm target
|
||||
rustup target add wasm32-unknown-unknown 2>/dev/null || true
|
||||
}
|
||||
|
||||
do_local_build() {
|
||||
# cd to project root (script may be run from scripts/ or project root)
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
local project_root="$(dirname "$script_dir")"
|
||||
cd "$project_root"
|
||||
echo " Project root: $(pwd)"
|
||||
|
||||
local arch
|
||||
arch=$(uname -m)
|
||||
local os
|
||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
local out_dir="target/${os}-${arch}"
|
||||
|
||||
echo "=== Local Build (${os}-${arch}) ==="
|
||||
|
||||
do_local_deps
|
||||
|
||||
echo "[2/4] Building WASM..."
|
||||
wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg 2>&1 | tail -3
|
||||
|
||||
echo "[3/4] Building release binaries..."
|
||||
cargo build --release --bin warzone-server --bin warzone-client 2>&1
|
||||
|
||||
echo "[4/4] Copying to ${out_dir}..."
|
||||
mkdir -p "$out_dir"
|
||||
cp target/release/warzone-server target/release/warzone-client "$out_dir/"
|
||||
cp federation.example.json "$out_dir/" 2>/dev/null || true
|
||||
|
||||
# Clean cargo cache if requested
|
||||
if [ "${CLEAN_CACHE:-}" = "1" ]; then
|
||||
echo " Cleaning build cache..."
|
||||
cargo clean 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Local Build Complete ==="
|
||||
ls -lh "$out_dir"/warzone-*
|
||||
echo ""
|
||||
echo "Run:"
|
||||
echo " $out_dir/warzone-server --bind 0.0.0.0:7700"
|
||||
echo " $out_dir/warzone-client tui --server http://localhost:7700"
|
||||
}
|
||||
|
||||
do_local_ship() {
|
||||
do_local_build
|
||||
echo ""
|
||||
do_update_all
|
||||
echo ""
|
||||
do_status
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " LOCAL SHIP COMPLETE"
|
||||
echo "========================================"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --ship: Build + deploy to all servers + destroy VM (full pipeline)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_ship() {
|
||||
echo "========================================"
|
||||
echo " SHIPPING featherChat to production"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
do_prepare
|
||||
echo ""
|
||||
do_build
|
||||
echo ""
|
||||
do_transfer
|
||||
echo ""
|
||||
do_update_all
|
||||
echo ""
|
||||
do_destroy
|
||||
echo ""
|
||||
do_status
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " SHIP COMPLETE"
|
||||
echo "========================================"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
case "${1:-}" in
|
||||
--prepare)
|
||||
do_prepare
|
||||
;;
|
||||
--build)
|
||||
do_build
|
||||
;;
|
||||
--transfer)
|
||||
do_transfer
|
||||
;;
|
||||
--destroy)
|
||||
do_destroy
|
||||
;;
|
||||
--deploy)
|
||||
do_deploy "$@"
|
||||
;;
|
||||
--update)
|
||||
do_update "${2:-}"
|
||||
;;
|
||||
--update-all)
|
||||
do_update_all
|
||||
;;
|
||||
--status)
|
||||
do_status
|
||||
;;
|
||||
--logs)
|
||||
do_logs "${2:-}"
|
||||
;;
|
||||
--all)
|
||||
do_prepare
|
||||
do_build
|
||||
do_transfer
|
||||
echo ""
|
||||
echo "VM is still running. Destroy with: ./scripts/build-linux.sh --destroy"
|
||||
;;
|
||||
--ship)
|
||||
do_ship
|
||||
;;
|
||||
--local)
|
||||
do_local_build
|
||||
;;
|
||||
--local-ship)
|
||||
do_local_ship
|
||||
;;
|
||||
--local-clean)
|
||||
CLEAN_CACHE=1 do_local_build
|
||||
;;
|
||||
--upload)
|
||||
do_upload
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 <command> [args]"
|
||||
echo ""
|
||||
echo "Local build:"
|
||||
echo " --local Build locally (auto-detect OS, install deps)"
|
||||
echo " --local-ship Build locally + deploy to all servers"
|
||||
echo " --local-clean Build locally + clean cargo cache after"
|
||||
echo ""
|
||||
echo "Remote build (Hetzner VM):"
|
||||
echo " --ship Build on VM + deploy + destroy VM"
|
||||
echo " --prepare Create VM, install deps, upload source"
|
||||
echo " --build Build release binaries on VM"
|
||||
echo " --transfer Download binaries to $OUTPUT_DIR"
|
||||
echo " --destroy Delete the build VM"
|
||||
echo " --all prepare + build + transfer (VM persists)"
|
||||
echo " --upload Re-upload source to existing VM"
|
||||
echo ""
|
||||
echo "Deploy:"
|
||||
echo " --update <user@host> Stop service, upload binaries, restart"
|
||||
echo " --update-all Update mequ + kh3rad3ree"
|
||||
echo " --deploy <user@host> First-time deploy (upload + start)"
|
||||
echo ""
|
||||
echo "Monitor:"
|
||||
echo " --status Check service status on all servers"
|
||||
echo " --logs [user@host] Tail server logs (default: mequ)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
38
warzone/tools/README.md
Normal file
38
warzone/tools/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# featherChat Bot Tools
|
||||
|
||||
## bot-bridge.py
|
||||
|
||||
Proxy server that makes featherChat compatible with Telegram bot libraries.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Register a bot on featherChat
|
||||
curl -X POST http://server:7700/v1/bot/register \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"MyBot","fingerprint":"aabbccddaabbccddaabbccddaabbccdd"}'
|
||||
|
||||
# 2. Start the bridge
|
||||
python3 tools/bot-bridge.py --server http://server:7700 --token YOUR_TOKEN --port 8081
|
||||
|
||||
# 3. Point your TG bot at the bridge
|
||||
# Python (python-telegram-bot):
|
||||
# bot = Bot(token="TOKEN", base_url="http://localhost:8081/botTOKEN")
|
||||
# Node (Telegraf):
|
||||
# const bot = new Telegraf("TOKEN", { telegram: { apiRoot: "http://localhost:8081" } })
|
||||
```
|
||||
|
||||
### What it does
|
||||
|
||||
- Translates Telegram API calls to featherChat Bot API
|
||||
- Converts numeric chat_id <-> fingerprint hex strings
|
||||
- Proxies getUpdates long-polling
|
||||
- Passes through sendMessage, editMessageText, etc.
|
||||
|
||||
### Future: E2E Mode
|
||||
|
||||
When E2E bot support is complete, the bridge will:
|
||||
- Hold the bot's seed/keypair
|
||||
- Decrypt incoming E2E messages before forwarding to the TG bot
|
||||
- Encrypt outgoing messages with the user's ratchet session
|
||||
- The TG bot sees plaintext; the server sees only ciphertext
|
||||
175
warzone/tools/bot-bridge.py
Executable file
175
warzone/tools/bot-bridge.py
Executable file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
featherChat E2E Bot Bridge
|
||||
|
||||
Runs a local Telegram-compatible API server that proxies to featherChat.
|
||||
Your Telegram bot connects to this bridge instead of api.telegram.org.
|
||||
|
||||
Usage:
|
||||
python bot-bridge.py --server http://featherchat:7700 --token YOUR_BOT_TOKEN --port 8081
|
||||
|
||||
Your bot code:
|
||||
# Instead of: bot = Bot(token="...", base_url="https://api.telegram.org")
|
||||
# Use: bot = Bot(token="...", base_url="http://localhost:8081")
|
||||
|
||||
Architecture:
|
||||
[TG Bot] <--HTTP--> [Bridge :8081] <--HTTP--> [featherChat :7700]
|
||||
|
||||
The bridge translates between Telegram API format and featherChat Bot API,
|
||||
handling the chat_id type differences and other incompatibilities.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
class BotBridgeHandler(BaseHTTPRequestHandler):
|
||||
server_url = ""
|
||||
bot_token = ""
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f"[bridge] {args[0]}" if args else "")
|
||||
|
||||
def do_GET(self):
|
||||
self._proxy()
|
||||
|
||||
def do_POST(self):
|
||||
self._proxy()
|
||||
|
||||
def _proxy(self):
|
||||
# Extract the method from URL: /bot<token>/methodName
|
||||
path = self.path
|
||||
|
||||
# Strip /bot<token>/ prefix if present (TG libraries send this)
|
||||
if path.startswith(f'/bot{self.bot_token}/'):
|
||||
method = path[len(f'/bot{self.bot_token}/'):]
|
||||
elif path.startswith('/bot'):
|
||||
# Library might send a different token format
|
||||
parts = path.split('/', 3)
|
||||
method = parts[3] if len(parts) > 3 else parts[-1]
|
||||
else:
|
||||
method = path.lstrip('/')
|
||||
|
||||
# Read request body
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(content_length) if content_length > 0 else b''
|
||||
|
||||
# Transform request for featherChat
|
||||
fc_url = f"{self.server_url}/v1/bot/{self.bot_token}/{method}"
|
||||
|
||||
# Transform body if needed
|
||||
if body and method == 'sendMessage':
|
||||
body = self._transform_send_message(body)
|
||||
|
||||
try:
|
||||
req = Request(fc_url, data=body if body else None, method=self.command)
|
||||
req.add_header('Content-Type', 'application/json')
|
||||
|
||||
with urlopen(req, timeout=60) as resp:
|
||||
response_body = resp.read()
|
||||
|
||||
# Transform response
|
||||
if method == 'getUpdates':
|
||||
response_body = self._transform_updates(response_body)
|
||||
|
||||
self.send_response(resp.status)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(response_body)
|
||||
|
||||
except URLError as e:
|
||||
self.send_response(502)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({
|
||||
"ok": False,
|
||||
"description": f"Bridge error: {e}"
|
||||
}).encode())
|
||||
|
||||
def _transform_send_message(self, body):
|
||||
"""Transform sendMessage: convert numeric chat_id to string if needed."""
|
||||
try:
|
||||
data = json.loads(body)
|
||||
# chat_id: featherChat accepts both string and number now
|
||||
# No transformation needed -- pass through
|
||||
return json.dumps(data).encode()
|
||||
except:
|
||||
return body
|
||||
|
||||
def _transform_updates(self, body):
|
||||
"""Transform getUpdates response: ensure chat_id is integer for TG libs."""
|
||||
try:
|
||||
data = json.loads(body)
|
||||
if data.get('ok') and data.get('result'):
|
||||
for update in data['result']:
|
||||
msg = update.get('message', {})
|
||||
# Convert string IDs to numeric for TG library compatibility
|
||||
if 'from' in msg and isinstance(msg['from'].get('id'), str):
|
||||
fp = msg['from']['id']
|
||||
msg['from']['id_str'] = fp
|
||||
msg['from']['id'] = _fp_to_numeric(fp)
|
||||
if 'chat' in msg and isinstance(msg['chat'].get('id'), str):
|
||||
fp = msg['chat']['id']
|
||||
msg['chat']['id_str'] = fp
|
||||
msg['chat']['id'] = _fp_to_numeric(fp)
|
||||
return json.dumps(data).encode()
|
||||
except:
|
||||
return body
|
||||
|
||||
|
||||
def _fp_to_numeric(fp: str) -> int:
|
||||
"""Convert fingerprint hex string to positive i64 (same as server's fp_to_numeric_id)."""
|
||||
clean = ''.join(c for c in fp if c in '0123456789abcdefABCDEF')[:16]
|
||||
if len(clean) >= 16:
|
||||
return int(clean, 16) & 0x7FFFFFFFFFFFFFFF
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='featherChat E2E Bot Bridge')
|
||||
parser.add_argument('--server', required=True, help='featherChat server URL (e.g., http://localhost:7700)')
|
||||
parser.add_argument('--token', required=True, help='Bot token from /v1/bot/register')
|
||||
parser.add_argument('--port', type=int, default=8081, help='Local port for TG-compatible API (default: 8081)')
|
||||
args = parser.parse_args()
|
||||
|
||||
BotBridgeHandler.server_url = args.server.rstrip('/')
|
||||
BotBridgeHandler.bot_token = args.token
|
||||
|
||||
# Verify bot token
|
||||
try:
|
||||
req = Request(f"{args.server}/v1/bot/{args.token}/getMe")
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read())
|
||||
if not data.get('ok'):
|
||||
print(f"ERROR: Invalid bot token")
|
||||
sys.exit(1)
|
||||
bot_name = data['result'].get('first_name', '?')
|
||||
print(f"Bot: {bot_name}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: Cannot reach server: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
server = HTTPServer(('127.0.0.1', args.port), BotBridgeHandler)
|
||||
print(f"Bridge running on http://127.0.0.1:{args.port}")
|
||||
print(f"Proxying to {args.server}")
|
||||
print(f"")
|
||||
print(f"Configure your bot:")
|
||||
print(f" base_url = 'http://127.0.0.1:{args.port}/bot{args.token}'")
|
||||
print(f"")
|
||||
print(f"Example (python-telegram-bot):")
|
||||
print(f" from telegram import Bot")
|
||||
print(f" bot = Bot(token='{args.token}', base_url='http://127.0.0.1:{args.port}/bot{args.token}')")
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nBridge stopped.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
195
warzone/tools/botfather.py
Executable file
195
warzone/tools/botfather.py
Executable file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
featherChat BotFather (Standalone)
|
||||
|
||||
A Telegram-style BotFather that manages bot creation via chat.
|
||||
Uses the featherChat Bot API — runs as a regular bot process.
|
||||
|
||||
Usage:
|
||||
python botfather.py --server http://localhost:7700
|
||||
|
||||
On first run, it registers itself as @botfather if not already registered.
|
||||
Subsequent runs reuse the stored token from .botfather_token file.
|
||||
|
||||
Commands:
|
||||
/start, /help - Show help
|
||||
/newbot <name> - Create a new bot (name must end with bot/Bot)
|
||||
/mybots - List your bots
|
||||
/deletebot <n> - Delete a bot you own
|
||||
/token <name> - Show token for your bot
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
TOKEN_FILE = ".botfather_token"
|
||||
|
||||
|
||||
def api(server, token, method, data=None):
|
||||
"""Call a bot API method."""
|
||||
url = f"{server}/v1/bot/{token}/{method}"
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = Request(url, data=body, method="POST" if body else "GET")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read())
|
||||
except URLError as e:
|
||||
print(f"API error ({method}): {e}")
|
||||
return {"ok": False}
|
||||
|
||||
|
||||
def send(server, token, chat_id, text):
|
||||
"""Send a message."""
|
||||
return api(server, token, "sendMessage", {"chat_id": chat_id, "text": text})
|
||||
|
||||
|
||||
def register_botfather(server):
|
||||
"""Register BotFather with the server. Returns token."""
|
||||
# BotFather registers itself — it needs the built-in BotFather token
|
||||
# to authorize. Read it from the server's initial log or pass via env.
|
||||
builtin_token = os.environ.get("BOTFATHER_TOKEN", "")
|
||||
if not builtin_token:
|
||||
print("ERROR: Set BOTFATHER_TOKEN env var to the token from server logs")
|
||||
print(" (printed on first --enable-bots start)")
|
||||
sys.exit(1)
|
||||
|
||||
# Use the built-in token directly
|
||||
return builtin_token
|
||||
|
||||
|
||||
def handle_message(server, token, msg):
|
||||
"""Process a message and respond."""
|
||||
text = (msg.get("text") or "").strip()
|
||||
chat_id = msg.get("chat", {}).get("id", "")
|
||||
from_id = msg.get("from", {}).get("id_str") or str(msg.get("from", {}).get("id", ""))
|
||||
|
||||
if not text or not chat_id:
|
||||
return
|
||||
|
||||
print(f"[{from_id[:16]}] {text}")
|
||||
|
||||
if text in ("/start", "/help"):
|
||||
send(server, token, chat_id,
|
||||
"Welcome to BotFather! I manage bots on featherChat.\n\n"
|
||||
"Commands:\n"
|
||||
"/newbot <name> - Create a bot (name must end with bot/Bot)\n"
|
||||
"/mybots - List your bots\n"
|
||||
"/deletebot <name> - Delete your bot\n"
|
||||
"/token <name> - Get bot token\n"
|
||||
"/help - Show this message")
|
||||
|
||||
elif text.startswith("/newbot"):
|
||||
name = text.replace("/newbot", "").strip()
|
||||
if not name:
|
||||
send(server, token, chat_id, "Usage: /newbot <botname>\nExample: /newbot WeatherBot")
|
||||
return
|
||||
|
||||
if len(name) < 3 or len(name) > 32:
|
||||
send(server, token, chat_id, "Bot name must be 3-32 characters.")
|
||||
return
|
||||
|
||||
if not name.lower().endswith("bot"):
|
||||
send(server, token, chat_id, "Bot name must end with 'bot' or 'Bot'.")
|
||||
return
|
||||
|
||||
# Create the bot via internal API
|
||||
fp = os.urandom(16).hex()
|
||||
resp = api(server, token, "../register", {
|
||||
"name": name,
|
||||
"fingerprint": fp,
|
||||
"botfather_token": token,
|
||||
"owner": from_id
|
||||
})
|
||||
|
||||
if resp.get("ok"):
|
||||
result = resp["result"]
|
||||
send(server, token, chat_id,
|
||||
f"Done! Your new bot @{result.get('alias', name.lower())} is ready.\n\n"
|
||||
f"Token: {result['token']}\n\n"
|
||||
f"Keep this token secret!")
|
||||
else:
|
||||
send(server, token, chat_id, f"Failed: {resp.get('description', 'unknown error')}")
|
||||
|
||||
elif text == "/mybots":
|
||||
send(server, token, chat_id,
|
||||
"Use the built-in /mybots via chat with @botfather.\n"
|
||||
"(The built-in handler tracks ownership.)")
|
||||
|
||||
elif text.startswith("/deletebot"):
|
||||
name = text.replace("/deletebot", "").strip()
|
||||
if not name:
|
||||
send(server, token, chat_id, "Usage: /deletebot <botname>")
|
||||
return
|
||||
send(server, token, chat_id,
|
||||
f"Use the built-in /deletebot {name} via chat with @botfather.\n"
|
||||
"(The built-in handler verifies ownership.)")
|
||||
|
||||
elif text.startswith("/token"):
|
||||
name = text.replace("/token", "").strip()
|
||||
if not name:
|
||||
send(server, token, chat_id, "Usage: /token <botname>")
|
||||
return
|
||||
send(server, token, chat_id,
|
||||
f"Use the built-in /token {name} via chat with @botfather.\n"
|
||||
"(The built-in handler verifies ownership.)")
|
||||
|
||||
else:
|
||||
send(server, token, chat_id, "Unknown command. Try /help")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="featherChat BotFather (standalone)")
|
||||
parser.add_argument("--server", required=True, help="featherChat server URL")
|
||||
parser.add_argument("--token", help="BotFather token (or set BOTFATHER_TOKEN env)")
|
||||
args = parser.parse_args()
|
||||
|
||||
token = args.token or os.environ.get("BOTFATHER_TOKEN", "")
|
||||
|
||||
# Try loading from file
|
||||
if not token and os.path.exists(TOKEN_FILE):
|
||||
token = open(TOKEN_FILE).read().strip()
|
||||
|
||||
if not token:
|
||||
token = register_botfather(args.server)
|
||||
|
||||
# Save token
|
||||
with open(TOKEN_FILE, "w") as f:
|
||||
f.write(token)
|
||||
|
||||
# Verify
|
||||
me = api(args.server, token, "getMe")
|
||||
if not me.get("ok"):
|
||||
print(f"ERROR: Invalid token. Delete {TOKEN_FILE} and retry.")
|
||||
sys.exit(1)
|
||||
|
||||
bot_name = me["result"].get("first_name", "BotFather")
|
||||
print(f"BotFather ({bot_name}) running")
|
||||
print(f"Server: {args.server}")
|
||||
print(f"Polling for messages...")
|
||||
print()
|
||||
|
||||
offset = 0
|
||||
while True:
|
||||
try:
|
||||
resp = api(args.server, token, "getUpdates", {"offset": offset, "timeout": 30})
|
||||
for update in resp.get("result", []):
|
||||
offset = update["update_id"] + 1
|
||||
msg = update.get("message", {})
|
||||
if msg:
|
||||
handle_message(args.server, token, msg)
|
||||
except KeyboardInterrupt:
|
||||
print("\nBotFather stopped.")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user