31 Commits

Author SHA1 Message Date
Siavash Sameni
7c248442c2 feat: /testcall /testecho /testmic — local audio diagnostics
- /testcall: plays 3 tones (440/880/660Hz) to test speaker
- /testecho: mic → speaker loopback with 100ms delay (/stopecho to end)
- /testmic: records 3 seconds, plays back (tests both mic + speaker)

No relay or peer needed — pure local browser audio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:38:26 +04:00
Siavash Sameni
5ae87be316 fix: remove --auth-url from wzp-relay (wzp-web doesn't send auth to relay)
wzp-web connects to relay via QUIC and does crypto handshake directly,
but relay with --auth-url expects AuthToken first → handshake fails.
Auth at relay level will be re-added when wzp-web learns to forward tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:29:50 +04:00
Siavash Sameni
f698b25fad fix: remove --auth-url from wzp-web (variants handle auth differently)
The 'full' variant sends a key exchange as first WS message, not auth.
The 'pure' variant sends raw PCM immediately, no auth.
Only ws/ws-fec/ws-full variants send auth JSON.

With auth removed, wzp-web accepts all WS connections. Auth is
still enforced on the relay (--auth-url) for direct connections.
Caddy provides access control at the TLS layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:19:43 +04:00
Siavash Sameni
7924871559 fix: set __WZP_BASE_URL before loading variant scripts (WASM path resolution)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:13:03 +04:00
Siavash Sameni
8a4f0ef8ee v0.0.47: integrate 6 WZP audio variants into featherChat calls
startAudio() now dynamically loads the selected WZP client variant:
- /audio-variant [pure|hybrid|full|ws|ws-fec|ws-full]
- Loads variant JS from wzp-web's /audio/js/ path via Caddy
- Falls back to inline pure implementation if variant fails to load
- Variant persisted in localStorage across sessions
- Call bar shows active variant: "In call [ws-fec] with 0x..."

Variants:
  pure     — raw PCM over WS (bridge needed, no WASM)
  hybrid   — raw PCM + WASM FEC over WS (bridge needed)
  full     — WebTransport + FEC + crypto (no bridge, future)
  ws       — WZP protocol over WS (relay direct)
  ws-fec   — WZP + WASM FEC over WS (relay direct)
  ws-full  — WZP + FEC + E2E crypto over WS (relay direct)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:02:43 +04:00
Siavash Sameni
561f2d6978 feat: variant testing — 6 subdomains + Caddy wildcard cert
- v1-v6.voip.manko.yoga → each maps to a WZP client variant
- Caddyfile.test: wildcard *.voip.manko.yoga with CF DNS cert
- scripts/test-variants.sh: --setup creates DNS + swaps Caddyfile
- --teardown cleans up DNS + restores original
- --check verifies all 6 respond HTTP 200
- All variants join same room for cross-variant audio testing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:01:38 +04:00
Siavash Sameni
da3cdd7234 feat: integrate wzp-web-variants, remove --tls from wzp-web
- wzp-web runs plain HTTP behind Caddy (no --tls)
- deploy-chat.sh clones feature/wzp-web-variants for warzone-phone
- Three audio variants: ?variant=pure|hybrid|full
- Auth kept on both wzp-relay and wzp-web

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:07:21 +04:00
Siavash Sameni
cc76004655 fix: use public HTTPS git URLs, remove SSH key upload
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:53:25 +04:00
Siavash Sameni
9af5ec96b5 feat: deploy-chat.sh — full Hetzner deploy for chat.manko.yoga
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:51:49 +04:00
Siavash Sameni
02471b28ba feat: start-voip.sh — update DNS locally + start Docker stack
- Removed dns-updater Docker sidecar (curl not available in alpine)
- scripts/start-voip.sh: updates DNS then docker compose up
- update-dns.sh: supports --once flag, runs locally with curl
- All CF API calls forced to IPv4 (-4 flag)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:39:23 +04:00
Siavash Sameni
74af18463e fix: install curl in dns-updater container
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:36:42 +04:00
Siavash Sameni
b22200e3be feat: dynamic DNS updater sidecar (auto-updates A + AAAA every 5min)
- update-dns.sh: detects public IPv4/IPv6, upserts CF records
- Runs on container start + every 5 minutes
- Only updates if IP actually changed (skips if unchanged)
- python:3-alpine container with curl

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:34:05 +04:00
Siavash Sameni
850944944d revert: Caddy back to bridge network (host mode breaks OrbStack)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:26:12 +04:00
Siavash Sameni
47030a3b29 fix: Caddy host network mode for real client IPs
- Caddy now uses network_mode: host (sees real IPv4/IPv6)
- All backend services on fixed IPs (172.28.0.10/20/30)
- Caddyfile uses IPs instead of Docker DNS names
- /myip now returns actual client IP, not Docker gateway

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:19:23 +04:00
Siavash Sameni
cac812665c fix: Caddyfile adds X-Real-IP header + trusted_proxies config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:12:57 +04:00
Siavash Sameni
f272a82faf feat: /myip command + /v1/whoami endpoint
Server:
- GET /v1/whoami returns client IP, IPv4/IPv6 classification
- Detects proxy via X-Forwarded-For, X-Real-IP, Via headers
- Shows proxy details when behind reverse proxy (Caddy etc)
- ConnectInfo enabled for direct socket address

Web client:
- /myip, /whatsmyip, /ip — shows your IP + proxy info
- Useful for testing IPv4/IPv6 connectivity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:11:06 +04:00
Siavash Sameni
11133cf968 fix: use fixed IP for wzp-relay (wzp-web can't resolve hostnames)
wzp-web --relay only accepts IP:port, not Docker hostnames.
Fixed IP 172.28.0.10 on backend network with explicit subnet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:03:43 +04:00
Siavash Sameni
8b00144b2f fix: force IPv4 in Caddy build (Docker lacks IPv6 during build)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:56:53 +04:00
Siavash Sameni
bf9594f1de fix: use debian:trixie-slim runtime (match rust:latest glibc)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:54:44 +04:00
Siavash Sameni
366ab30988 fix: install cmake in wzp Docker build (opus codec dependency)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:41:57 +04:00
Siavash Sameni
fb29eb0fce fix: build WASM before server (include_str! needs wasm-pkg)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:31:31 +04:00
Siavash Sameni
33c39c6541 deploy: add deploy-voip.sh + fix Rust version (use latest)
- scripts/deploy-voip.sh: full Hetzner cx23 + Docker + CF DNS deploy
  --create: provision VPS, install Docker
  --dns: update CF A + AAAA records
  --deploy: upload source, docker compose up
  --test: 6 HTTP checks + TLS + IPv6
  --all: end-to-end in one command
- Dockerfiles: use rust:latest (time crate needs 1.88+)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:26:46 +04:00
Siavash Sameni
3d387e5821 fix: copy warzone-protocol into wzp build (deps/featherchat path)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:21:40 +04:00
Siavash Sameni
38f992c284 fix: bump Docker Rust to 1.85 (edition 2024 support for wzp-proto)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:19:50 +04:00
Siavash Sameni
59d68b2a5e fix: build Caddy with CF plugin from source (ARM64 compat)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:14:33 +04:00
Siavash Sameni
f33ac1cad8 deploy: Docker Compose stack with Caddy + Cloudflare TLS
Full production stack via docker compose:
- Caddy reverse proxy with Cloudflare DNS-01 TLS certs
- warzone-server (featherChat API + web UI)
- wzp-relay (QUIC audio SFU)
- wzp-web (browser WS ↔ QUIC bridge)

Architecture:
  Internet → Caddy (443/TLS) → voip.manko.yoga
    /*       → warzone-server:7700
    /audio/* → wzp-web:8080

Files:
- docker-compose.yml: main stack (4 services)
- docker-compose.ipv6.yml: IPv6 overlay
- Caddyfile: Cloudflare DNS challenge + reverse proxy
- Dockerfile.server: featherChat multi-stage build
- Dockerfile.wzp: wzp-relay + wzp-web multi-stage build
- .env.example: DNS records for dev/staging/prod
- test-stack.sh: smoke test (8 checks)
- .dockerignore: excludes target/, .git/, etc.

Deployment targets:
  dev:  172.16.81.135
  ipv6: 2a0d:3344:692c:2500:14f2:5885:d73c:b0a1
  prod: 63.250.54.239 / 2602:ff16:9:0:1:3d9:0:1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:00:47 +04:00
Siavash Sameni
c2be68ca20 docs: comprehensive update all docs to v0.0.46
11 files updated to reflect current state (v0.0.22 → v0.0.46):

ARCHITECTURE.md:
- Ring tones, group calls, read receipts, markdown rendering sections
- Bot API expanded (BotFather, numeric IDs, Telegram compat)
- Admin commands, known issues, 155 tests

TASK_PLAN.md:
- All P1-P4 marked DONE with version numbers
- Additional completed work section (bots, ETH, ring tones, group calls)
- New FC-P7 (Voice & Transport): cpal, Sender Keys, WebTransport
- FC-P6-T9/T10 added

PROGRESS.md:
- Full version history table v0.0.22 through v0.0.46
- Known issues section

README.md:
- Voice calls, ring tones, group calls, read receipts, markdown, 155 tests

SECURITY.md:
- Bot API security, voice call security, admin commands sections
- Updated protection tables

USAGE.md:
- Group calls, read receipts, markdown formatting, admin commands

CLIENT.md:
- Call commands, read receipts, markdown rendering

LLM_HELP.md + LLM_BOT_DEV.md:
- Call/group call/admin commands, ring tones, per-bot numeric IDs

TESTING_E2E.md:
- Tests 16-18: ring tones, group calls, admin commands

CLAUDE.md:
- Ring tone notes, group signal endpoint, MLS roadmap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:47:13 +04:00
Siavash Sameni
d7b75a6641 roadmap: add MLS (RFC 9420) for E2E group call encryption
- FC-P5-T5 added to task plan with full design notes
- OpenMLS/TreeKEM approach: O(log n) key rotation, forward secrecy
- Current group calls marked as transport-encrypted only (QUIC)
- UI warning shown when starting group call
- Updated completed task statuses (read receipts, wrapping, tab complete)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:33:20 +04:00
Siavash Sameni
93923676a8 v0.0.46: fix group calls, admin commands, ETH in members
Server:
- POST /v1/groups/:name/signal — broadcast plaintext JSON to all
  online group members via WS push (for call signals, typing, etc.)
- No auth required (membership validated from 'from' field)

Web client:
- Group call signals now use /signal endpoint (was broken with /send)
- WS handler detects group_call JSON signals early
- Auto-join #ops now properly registers presence + fetches members
- /gmembers resolves ETH addresses via /v1/resolve
- /admin-calls — list all active calls in the system
- /admin-help — list all admin/debug commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:31:00 +04:00
Siavash Sameni
2612d46f5c v0.0.45: call ring tones + group calls
Direct call ringing:
- Incoming: oscillator ring tone (440/480Hz, on/off pattern)
- Outgoing: ringback tone (2s on, 4s off) while waiting for answer
- Browser Notification API for background incoming calls
- All tones stop on answer/reject/hangup
- Notification permission requested on first click

Group calls:
- /gcall — start a group call (notifies all group members)
- /gjoin — join an active group call
- /gleave-call — leave group call
- /gmute — toggle per-group call notification mute (localStorage)
- Participant tracking (joined/left messages with count)
- Group call signal via group message broadcast (JSON type: group_call)
- Call bar shows "Join Call" button when group call is active
- Audio via same WZP bridge (room = gc-<groupname>)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:12:25 +04:00
Siavash Sameni
983afc5916 Merge feature/wzp-call-infrastructure: v0.0.22→v0.0.44
50 commits: call infrastructure, bot API, federation, web/TUI polish.

Key features:
- Voice calls via WZP audio bridge (signaling + audio)
- Telegram-compatible Bot API (BotFather, getUpdates, sendMessage)
- Server-to-server federation (persistent WS, presence sync)
- Web: markdown, call UI, ETH display, inline keyboards
- TUI: markdown, read receipts, call commands, tab completion
- Session versioning, wire envelope format, auto-backup
- 155 tests passing
2026-03-30 08:52:30 +04:00
34 changed files with 2743 additions and 197 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
**/target
**/node_modules
**/.git
**/.DS_Store
**/.claude
**/wasm-pkg
apache
nginx
nginx.txt
chat.py
tunnel.py
DESIGN.md

View File

@@ -14,7 +14,7 @@ Never commit functional changes without bumping all four. The service worker cac
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).
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). Group calls are transport-encrypted only (QUIC/TLS); MLS (RFC 9420) E2E encryption for group calls is planned but not yet implemented.
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.
@@ -44,6 +44,7 @@ Never commit functional changes without bumping all four. The service worker cac
- 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
- Ring tones use Web Audio API oscillators (no audio files) — see `startRingTone()`/`startRingbackTone()`/`stopRingTone()` in `web.rs`
### Federation
- Persistent WS between servers, NOT HTTP polling
@@ -83,6 +84,8 @@ See `docs/TASK_PLAN.md` for the full breakdown.
| TUI commands | `warzone-client/src/tui/commands.rs` |
| Web client | `warzone-server/src/routes/web.rs` |
| WASM bridge | `warzone-wasm/src/lib.rs` |
| Group signal endpoint | `warzone-server/src/routes/groups.rs` (`signal_group`) |
| Ring tone functions | `warzone-server/src/routes/web.rs` (`startRingTone`, `startRingbackTone`, `stopRingTone`) |
| Task plan | `docs/TASK_PLAN.md` |
| Bot API docs | `docs/BOT_API.md` |
| LLM help ref | `docs/LLM_HELP.md` |

10
warzone/Cargo.lock generated
View File

@@ -2956,7 +2956,7 @@ dependencies = [
[[package]]
name = "warzone-client"
version = "0.0.44"
version = "0.0.47"
dependencies = [
"anyhow",
"argon2",
@@ -2989,7 +2989,7 @@ dependencies = [
[[package]]
name = "warzone-mule"
version = "0.0.44"
version = "0.0.47"
dependencies = [
"anyhow",
"clap",
@@ -2998,7 +2998,7 @@ dependencies = [
[[package]]
name = "warzone-protocol"
version = "0.0.44"
version = "0.0.47"
dependencies = [
"base64",
"bincode",
@@ -3023,7 +3023,7 @@ dependencies = [
[[package]]
name = "warzone-server"
version = "0.0.44"
version = "0.0.47"
dependencies = [
"anyhow",
"axum",
@@ -3054,7 +3054,7 @@ dependencies = [
[[package]]
name = "warzone-wasm"
version = "0.0.44"
version = "0.0.47"
dependencies = [
"base64",
"bincode",

View File

@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
version = "0.0.44"
version = "0.0.47"
edition = "2021"
license = "MIT"
rust-version = "1.75"

View File

@@ -6,8 +6,13 @@ End-to-end encrypted messenger with Signal protocol cryptography, voice/video ca
- **E2E Encrypted DMs** — X3DH key exchange + Double Ratchet (forward secrecy)
- **Group Messaging** — Sender Key protocol (O(1) encryption, fan-out delivery)
- **Voice Calls (WZP)** — DM and group calls via WarzonePhone audio bridge (QUIC SFU relay, ChaCha20-Poly1305 media)
- **Ring Tones** — Audible ring on incoming calls (web client)
- **Group Calls** — Multi-party audio via /gcall, /gjoin, /gleave-call, /gmute
- **Read Receipts** — Sent, delivered, and read indicators (viewport-based)
- **Markdown Rendering** — Bold, italic, inline code, headers, quotes, and lists in TUI and web
- **File Transfer** — Chunked (64KB), SHA-256 verified, ratchet-encrypted
- **Voice/Video Calls** — WarzonePhone integration (QUIC SFU relay, ChaCha20-Poly1305 media)
- **Admin Commands** — /admin-calls, /admin-unalias for server administration
- **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)
@@ -62,6 +67,20 @@ cargo build --release
./target/release/warzone-client tui --server http://localhost:7700
```
### WZP Setup (Voice Calls)
To enable voice calls, run a WarzonePhone relay alongside the server:
```bash
# Start the WZP QUIC relay (default port 7701)
./target/release/wzp-relay --bind 0.0.0.0:7701
# Start the server with WZP integration
./target/release/warzone-server --bind 0.0.0.0:7700 --wzp-relay http://localhost:7701
```
DM calls use `/call @alias`, group calls use `/gcall` within a group context.
### Federation (Two Servers)
Create `alpha.json`:
@@ -90,7 +109,13 @@ Messages automatically route across servers.
|---------|-------------|
| `/peer <fp>` or `/p @alias` | Set DM peer |
| `/g <name>` | Switch to group (auto-join) |
| `/call <fp>` | Initiate call |
| `/call <fp>` | Initiate DM voice call |
| `/accept` / `/reject` | Accept or reject incoming call |
| `/hangup` | End current call |
| `/gcall` | Start group call in current group |
| `/gjoin` | Join active group call |
| `/gleave-call` | Leave group call |
| `/gmute` | Toggle mute in group call |
| `/file <path>` | Send file (max 10MB) |
| `/contacts` | List contacts with message counts |
| `/history` | Show conversation history |
@@ -132,9 +157,9 @@ 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)
155 tests across protocol + client crates (all passing):
- Protocol tests (X3DH, Double Ratchet, Sender Keys, crypto, identity, call signaling)
- TUI tests (rendering, keyboard input, scrolling, state management, call UI, markdown, receipts)
```bash
cargo test --workspace

View File

@@ -1,6 +1,6 @@
[package]
name = "warzone-protocol"
version = "0.0.44"
version = "0.0.47"
edition = "2021"
license = "MIT"
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"

View File

@@ -248,7 +248,7 @@ async fn main() -> anyhow::Result<()> {
let listener = tokio::net::TcpListener::bind(&cli.bind).await?;
tracing::info!("Listening on {}", cli.bind);
axum::serve(listener, app).await?;
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await?;
Ok(())
}

View File

@@ -18,6 +18,7 @@ pub fn routes() -> Router<AppState> {
.route("/groups/:name/leave", post(leave_group))
.route("/groups/:name/kick", post(kick_member))
.route("/groups/:name/members", get(get_members))
.route("/groups/:name/signal", post(signal_group))
}
#[derive(Serialize, Deserialize, Clone)]
@@ -305,3 +306,47 @@ async fn get_members(
"online_count": online_count,
})))
}
/// Broadcast a plaintext signal to all online group members via WS push.
/// Used for group calls, typing indicators, etc. — NOT for encrypted messages.
async fn signal_group(
State(state): State<AppState>,
Path(name): Path<String>,
Json(req): Json<serde_json::Value>,
) -> AppResult<Json<serde_json::Value>> {
let group = match load_group(&state.db.groups, &name) {
Some(g) => g,
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
};
let from = req
.get("from")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let from = normalize_fp(&from);
if !group.members.contains(&from) {
return Ok(Json(serde_json::json!({ "error": "not a member" })));
}
// Broadcast the raw JSON payload to all online members except sender
let payload = serde_json::to_vec(&req).unwrap_or_default();
let mut pushed = 0;
for member in &group.members {
if *member == from {
continue;
}
if state.push_to_client(member, &payload).await {
pushed += 1;
}
}
tracing::info!(
"Group '{}' signal from {}: pushed to {}/{} members",
name,
from,
pushed,
group.members.len() - 1
);
Ok(Json(serde_json::json!({ "ok": true, "pushed": pushed })))
}

View File

@@ -1,12 +1,66 @@
use axum::{routing::get, Json, Router};
use axum::{extract::ConnectInfo, http::HeaderMap, routing::get, Json, Router};
use serde_json::json;
use std::net::SocketAddr;
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new().route("/health", get(health))
Router::new()
.route("/health", get(health))
.route("/whoami", get(whoami))
}
async fn health() -> Json<serde_json::Value> {
Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
}
async fn whoami(
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Json<serde_json::Value> {
// Prefer X-Forwarded-For (set by Caddy/reverse proxy), then X-Real-Ip, then direct
let forwarded = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.map(|v| v.split(',').next().unwrap_or("").trim().to_string());
let real_ip = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let direct = connect_info.map(|ci| ci.0.ip().to_string());
let ip = forwarded.clone()
.or(real_ip.clone())
.or(direct.clone())
.unwrap_or_else(|| "unknown".to_string());
// Classify as IPv4 or IPv6
let is_v6 = ip.contains(':');
let via = headers.get("via").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
let proto = headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
let host = headers.get("x-forwarded-host").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
let behind_proxy = forwarded.is_some() || real_ip.is_some() || via.is_some();
let mut result = json!({
"ip": ip,
"version": if is_v6 { "IPv6" } else { "IPv4" },
"direct": direct,
"behind_proxy": behind_proxy,
});
if behind_proxy {
let proxy = json!({
"x_forwarded_for": forwarded,
"x_real_ip": real_ip,
"x_forwarded_proto": proto,
"x_forwarded_host": host,
"via": via,
});
result.as_object_mut().unwrap().insert("proxy".to_string(), proxy);
}
Json(result)
}

View File

@@ -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-v26';
const CACHE = 'wz-v29';
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
self.addEventListener('install', e => {
@@ -288,7 +288,7 @@ let pollTimer = null;
let ws = null; // WebSocket connection
let wasmReady = false;
const VERSION = '0.0.44';
const VERSION = '0.0.47';
let DEBUG = true; // toggle with /debug command
// ── Receipt tracking ──
@@ -601,6 +601,10 @@ function connectWebSocket() {
// Text frame — could be a bot message or missed call notification
try {
const json = JSON.parse(event.data);
if (json.type === 'group_call') {
handleGroupCallSignal(json);
return;
}
if (json.type === 'missed_call') {
addSys('Missed call from ' + (json.data?.caller_fp || 'unknown'));
return;
@@ -701,6 +705,16 @@ function connectWebSocket() {
async function handleIncomingMessage(bytes) {
dbg('Processing message,', bytes.length, 'bytes, sessions:', Object.keys(sessions));
// Check for plaintext JSON signals (group call, typing, etc.) from /signal endpoint
try {
const textData = new TextDecoder().decode(bytes);
const signalData = JSON.parse(textData);
if (signalData.type === 'group_call') {
handleGroupCallSignal(signalData);
return;
}
} catch(e) {} // Not JSON or not a signal, continue with normal handling
// Quick check: try to parse as Receipt first (no session needed, no decrypt)
try {
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, null);
@@ -798,6 +812,11 @@ async function handleIncomingMessage(bytes) {
try {
const str = new TextDecoder().decode(bytes);
const json = JSON.parse(str);
// Check for group call signal
if (json.type === 'group_call') {
handleGroupCallSignal(json);
return;
}
if (json.type === 'file_header') { handleFileHeader(json); return; }
if (json.type === 'file_chunk') { handleFileChunk(json); return; }
// Handle bot messages (plaintext JSON from bot API)
@@ -1083,6 +1102,7 @@ async function enterChat() {
addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above');
addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info');
addSys('/call · /gcall · /gjoin · /gleave-call · /gmute');
// Show system bots if available
try {
@@ -1110,13 +1130,21 @@ async function enterChat() {
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)}) });
// Switch to #ops with full presence registration
currentGroup = 'ops';
$peerInput.value = '#ops';
localStorage.setItem('wz-peer', '#ops');
addSys('Welcome! You have been added to #ops');
// Fetch member list to confirm
const resp = await fetch(SERVER + '/v1/groups/ops');
if (resp.ok) {
const data = await resp.json();
if (data.members) {
addSys('Switched to group "ops" (' + data.members.length + ' members)');
}
}
} catch(e) { dbg('Auto-join #ops failed:', e); }
}, 500);
}
@@ -1237,6 +1265,13 @@ async function sendToGroup(groupName, text) {
let callState = 'idle'; // idle, calling, ringing, active
let callPeer = null;
let audioVariant = localStorage.getItem('wz-audio-variant') || 'pure';
let wzpClient = null; // The loaded WZP variant client instance
// Group call state
let groupCallRoom = null; // Current group call room name
let groupCallGroup = null; // Group name for active group call
let groupCallParticipants = []; // List of fingerprints in the call
function updateCallUI() {
const bar = document.getElementById('call-bar');
@@ -1274,7 +1309,7 @@ function updateCallUI() {
btnReject.style.display = '';
break;
case 'active':
status.textContent = '\u{1F50A} In call with ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '?').slice(0, 16));
status.textContent = '\u{1F50A} In call [' + audioVariant + '] with ' + (peerEthAddr ? peerEthAddr.slice(0, 12) + '...' : (callPeer || '?').slice(0, 16));
status.className = 'call-status';
btnHangup.style.display = '';
break;
@@ -1311,6 +1346,7 @@ async function startCall() {
payload.set(signalBytes, header.length);
ws.send(payload);
addSys('Calling ' + peer.slice(0, 16) + '...');
startRingback();
}
} catch(e) {
addSys('Call failed: ' + e.message);
@@ -1321,6 +1357,7 @@ async function startCall() {
function acceptCall() {
if (callState !== 'ringing') return;
stopRingtone();
callState = 'active';
updateCallUI();
@@ -1341,6 +1378,7 @@ function acceptCall() {
function rejectCall() {
if (callState !== 'ringing') return;
stopRingtone();
try {
const signalBytes = create_call_signal(wasmIdentity, 'reject', '', normFP(callPeer));
if (ws && ws.readyState === WebSocket.OPEN) {
@@ -1361,6 +1399,7 @@ function rejectCall() {
function hangupCall() {
if (callState === 'idle') return;
stopRingtone();
try {
const target = callPeer ? normFP(callPeer) : '';
const signalBytes = create_call_signal(wasmIdentity, 'hangup', '', target);
@@ -1389,6 +1428,8 @@ function handleCallSignal(signal) {
callState = 'ringing';
callPeer = sender;
updateCallUI();
startRingtone();
sendCallNotification('Incoming Call', 'Call from ' + (peerEthAddr || sender.slice(0, 16)));
addSys('\u{1F4DE} Incoming call from ' + sender.slice(0, 16));
// Play sound or vibrate
try { navigator.vibrate && navigator.vibrate([200, 100, 200]); } catch(e) {}
@@ -1398,6 +1439,7 @@ function handleCallSignal(signal) {
if (callState === 'calling') {
callState = 'active';
updateCallUI();
stopRingtone();
addSys('Call connected!');
startAudio();
}
@@ -1405,6 +1447,7 @@ function handleCallSignal(signal) {
case 'hangup':
case 'reject':
if (callState !== 'idle') {
stopRingtone();
stopAudio();
addSys('Call ended' + (type === 'reject' ? ' (rejected)' : ''));
callState = 'idle';
@@ -1438,15 +1481,100 @@ let mediaStream = null;
let captureNode = null;
let playbackNode = null;
// ═══════════════════════════════════════════════
// SECTION: Ring Tones (generated via Web Audio)
// ═══════════════════════════════════════════════
let ringCtx = null;
let ringOsc = null;
let ringGain = null;
let ringInterval = null;
function startRingtone() {
stopRingtone();
try {
ringCtx = new AudioContext();
ringGain = ringCtx.createGain();
ringGain.connect(ringCtx.destination);
ringGain.gain.value = 0;
ringOsc = ringCtx.createOscillator();
ringOsc.type = 'sine';
ringOsc.frequency.value = 440;
ringOsc.connect(ringGain);
ringOsc.start();
// Ring pattern: 400ms on, 200ms off, 400ms on, 1500ms off
let step = 0;
const pattern = [
{ gain: 0.3, freq: 440, dur: 400 },
{ gain: 0, freq: 440, dur: 200 },
{ gain: 0.3, freq: 480, dur: 400 },
{ gain: 0, freq: 480, dur: 1500 },
];
function tick() {
const p = pattern[step % pattern.length];
ringGain.gain.setValueAtTime(p.gain, ringCtx.currentTime);
ringOsc.frequency.setValueAtTime(p.freq, ringCtx.currentTime);
step++;
ringInterval = setTimeout(tick, p.dur);
}
tick();
} catch(e) { dbg('Ring tone error:', e); }
}
function startRingback() {
stopRingtone();
try {
ringCtx = new AudioContext();
ringGain = ringCtx.createGain();
ringGain.connect(ringCtx.destination);
ringGain.gain.value = 0;
ringOsc = ringCtx.createOscillator();
ringOsc.type = 'sine';
ringOsc.frequency.value = 440;
ringOsc.connect(ringGain);
ringOsc.start();
// Ringback: 2s on, 4s off (US standard)
let on = true;
function tick() {
ringGain.gain.setValueAtTime(on ? 0.15 : 0, ringCtx.currentTime);
ringOsc.frequency.setValueAtTime(on ? 440 : 480, ringCtx.currentTime);
on = !on;
ringInterval = setTimeout(tick, on ? 4000 : 2000);
}
tick();
} catch(e) { dbg('Ringback error:', e); }
}
function stopRingtone() {
if (ringInterval) { clearTimeout(ringInterval); ringInterval = null; }
if (ringOsc) { try { ringOsc.stop(); } catch(e) {} ringOsc = null; }
if (ringGain) { ringGain = null; }
if (ringCtx) { ringCtx.close().catch(() => {}); ringCtx = null; }
}
function sendCallNotification(title, body) {
if (document.hasFocus()) return;
if (Notification.permission === 'granted') {
const n = new Notification(title, { body, icon: '/icon.svg', tag: 'wz-call', requireInteraction: true });
n.onclick = () => { window.focus(); n.close(); };
return n;
} else if (Notification.permission !== 'denied') {
Notification.requestPermission();
}
}
async function startAudio() {
// Fetch relay config (includes auth token)
// Fetch relay config
let relayAddr, authToken;
try {
const resp = await fetch(SERVER + '/v1/wzp/relay-config');
const data = await resp.json();
relayAddr = data.relay_addr;
authToken = data.token;
dbg('Relay address:', relayAddr, 'token:', authToken);
} catch(e) {
addSys('Audio: cannot get relay config \u2014 ' + e.message);
return;
@@ -1464,7 +1592,7 @@ async function startAudio() {
audioCtx = new AudioContext({ sampleRate: 48000 });
// Deterministic room: sort both fingerprints so both peers get the same room
// Deterministic room name
const myFP = normFP(myFingerprint);
const peerFP = callPeer ? normFP(callPeer) : '';
const roomPair = [myFP, peerFP].sort().join('-');
@@ -1473,40 +1601,156 @@ async function startAudio() {
const proto = host.startsWith('localhost') || host.startsWith('127.') ? 'ws:' : 'wss:';
const wsUrl = proto + '//' + host + '/ws/' + room;
addSys('Audio: connecting to room ' + room.slice(0, 12) + '...');
addSys('Audio [' + audioVariant + ']: connecting to room ' + room.slice(0, 12) + '...');
// Load variant JS from wzp-web if not already loaded
const variantClass = await loadAudioVariant(audioVariant);
if (!variantClass) {
addSys('Audio: failed to load variant "' + audioVariant + '", falling back to pure');
// Fallback to inline pure implementation
startAudioPure(wsUrl, authToken, room);
return;
}
// Create variant client
wzpClient = new variantClass({
wsUrl: wsUrl,
room: room,
authToken: authToken,
onAudio: (pcm) => {
if (!audioCtx) return;
const float32 = new Float32Array(pcm.length);
for (let i = 0; i < pcm.length; i++) float32[i] = pcm[i] / 32768.0;
const buffer = audioCtx.createBuffer(1, float32.length, 48000);
buffer.getChannelData(0).set(float32);
const src = audioCtx.createBufferSource();
src.buffer = buffer;
src.connect(audioCtx.destination);
src.start();
},
onStatus: (msg) => addSys('Audio: ' + msg),
onStats: (stats) => dbg('Audio stats:', stats),
});
// Load WASM if variant needs it
if (wzpClient.loadWasm) {
try {
addSys('Audio: loading WASM module...');
await wzpClient.loadWasm();
} catch(e) {
addSys('Audio: WASM load failed \u2014 ' + e.message);
return;
}
}
// Connect
try {
await wzpClient.connect();
} catch(e) {
addSys('Audio: connection failed \u2014 ' + e.message);
wzpClient = null;
return;
}
// Start mic capture -> variant client
const source = audioCtx.createMediaStreamSource(mediaStream);
const processor = audioCtx.createScriptProcessor(1024, 1, 1);
let captureBuffer = new Float32Array(0);
processor.onaudioprocess = (e) => {
if (callState !== 'active' || !wzpClient) return;
const input = e.inputBuffer.getChannelData(0);
const combined = new Float32Array(captureBuffer.length + input.length);
combined.set(captureBuffer);
combined.set(input, captureBuffer.length);
captureBuffer = combined;
while (captureBuffer.length >= 960) {
const frame = captureBuffer.slice(0, 960);
captureBuffer = captureBuffer.slice(960);
const pcm = new Int16Array(frame.length);
for (let i = 0; i < frame.length; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
}
wzpClient.sendAudio(pcm.buffer);
}
};
source.connect(processor);
processor.connect(audioCtx.destination);
captureNode = processor;
}
// Dynamically load a WZP variant JS file from the audio bridge
async function loadAudioVariant(variant) {
const classMap = {
pure: 'WZPPureClient',
hybrid: 'WZPHybridClient',
full: 'WZPFullClient',
ws: 'WZPWsClient',
'ws-fec': 'WZPWsFecClient',
'ws-full': 'WZPWsFullClient',
};
const fileMap = {
pure: 'wzp-pure.js',
hybrid: 'wzp-hybrid.js',
full: 'wzp-full.js',
ws: 'wzp-ws.js',
'ws-fec': 'wzp-ws-fec.js',
'ws-full': 'wzp-ws-full.js',
};
const className = classMap[variant];
if (!className) return null;
// Already loaded?
if (window[className]) return window[className];
// Load from wzp-web's static files via the /audio/ Caddy path
const file = fileMap[variant];
if (!file) return null;
try {
// Set base URL so variant scripts resolve WASM imports via /audio/ path
window.__WZP_BASE_URL = SERVER + '/audio';
const script = document.createElement('script');
script.src = SERVER + '/audio/js/' + file;
await new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
return window[className] || null;
} catch(e) {
dbg('Failed to load variant script:', e);
return null;
}
}
// Fallback: inline pure audio (original implementation, no external JS)
function startAudioPure(wsUrl, authToken, room) {
audioWs = new WebSocket(wsUrl);
audioWs.binaryType = 'arraybuffer';
audioWs.onopen = async () => {
// Send auth token as first message (required by wzp-web --auth-url)
audioWs.send(JSON.stringify({ type: 'auth', token: authToken }));
addSys('Audio: connected \u2014 mic active');
addSys('Audio [pure-inline]: connected');
// Capture: mic -> PCM frames -> WS
const source = audioCtx.createMediaStreamSource(mediaStream);
// Use ScriptProcessor as fallback (AudioWorklet needs a separate file)
const bufferSize = 960; // 20ms at 48kHz
const processor = audioCtx.createScriptProcessor(1024, 1, 1);
let captureBuffer = new Float32Array(0);
processor.onaudioprocess = (e) => {
if (callState !== 'active' || !audioWs || audioWs.readyState !== WebSocket.OPEN) return;
const input = e.inputBuffer.getChannelData(0);
// Accumulate samples
const combined = new Float32Array(captureBuffer.length + input.length);
combined.set(captureBuffer);
combined.set(input, captureBuffer.length);
captureBuffer = combined;
// Send 960-sample frames (20ms)
while (captureBuffer.length >= bufferSize) {
const frame = captureBuffer.slice(0, bufferSize);
captureBuffer = captureBuffer.slice(bufferSize);
// Convert float32 to int16
while (captureBuffer.length >= 960) {
const frame = captureBuffer.slice(0, 960);
captureBuffer = captureBuffer.slice(960);
const pcm = new Int16Array(frame.length);
for (let i = 0; i < frame.length; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
@@ -1516,42 +1760,26 @@ async function startAudio() {
};
source.connect(processor);
processor.connect(audioCtx.destination); // needed to keep processor alive
processor.connect(audioCtx.destination);
captureNode = processor;
// Playback buffer
playbackNode = { queue: [] };
};
audioWs.onmessage = (event) => {
if (!audioCtx) return;
const pcm = new Int16Array(event.data);
if (pcm.length === 0) return;
// Convert int16 to float32 and play
const float32 = new Float32Array(pcm.length);
for (let i = 0; i < pcm.length; i++) {
float32[i] = pcm[i] / 32768.0;
}
for (let i = 0; i < pcm.length; i++) float32[i] = pcm[i] / 32768.0;
const buffer = audioCtx.createBuffer(1, float32.length, 48000);
buffer.getChannelData(0).set(float32);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
source.start();
const src = audioCtx.createBufferSource();
src.buffer = buffer;
src.connect(audioCtx.destination);
src.start();
};
audioWs.onclose = () => {
if (callState === 'active') {
addSys('Audio: disconnected');
}
};
audioWs.onerror = (e) => {
addSys('Audio: connection error');
dbg('Audio WS error:', e);
};
audioWs.onclose = () => { if (callState === 'active') addSys('Audio: disconnected'); };
audioWs.onerror = () => { addSys('Audio: connection error'); };
}
function stopAudio() {
@@ -1559,6 +1787,10 @@ function stopAudio() {
audioWs.close();
audioWs = null;
}
if (wzpClient) {
wzpClient.disconnect();
wzpClient = null;
}
if (captureNode) {
captureNode.disconnect();
captureNode = null;
@@ -1574,6 +1806,224 @@ function stopAudio() {
playbackNode = null;
}
// ═══════════════════════════════════════════════
// SECTION: Group Calls
// ═══════════════════════════════════════════════
async function startGroupCall() {
const peer = $peerInput.value.trim();
if (!peer || !peer.startsWith('#')) { addSys('Switch to a group first (/g <name>)'); return; }
const gname = peer.replace('#', '');
groupCallGroup = gname;
groupCallRoom = 'gc-' + gname;
groupCallParticipants = [normFP(myFingerprint)];
updateGroupCallUI();
// Notify group members via signal endpoint (plaintext broadcast)
const msg = JSON.stringify({ type: 'group_call', action: 'started', group: gname, room: groupCallRoom, from: normFP(myFingerprint) });
await fetch(SERVER + '/v1/groups/' + gname + '/signal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: msg
}).catch(() => {});
addSys('Group call started in #' + gname + ' \u2014 waiting for others to join');
addSys('\u{26A0}\u{FE0F} Group calls are transport-encrypted (QUIC), not E2E encrypted');
await joinGroupCallAudio();
}
async function joinGroupCall(gname, room) {
groupCallGroup = gname;
groupCallRoom = room;
if (!groupCallParticipants.includes(normFP(myFingerprint))) {
groupCallParticipants.push(normFP(myFingerprint));
}
updateGroupCallUI();
// Notify others we joined via signal endpoint
const msg = JSON.stringify({ type: 'group_call', action: 'joined', group: gname, room: room, from: normFP(myFingerprint) });
await fetch(SERVER + '/v1/groups/' + gname + '/signal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: msg
}).catch(() => {});
addSys('Joined group call in #' + gname);
await joinGroupCallAudio();
}
async function joinGroupCallAudio() {
// Reuse the audio bridge \u2014 connect to wzp-web with group room name
let relayAddr, token;
try {
const resp = await fetch(SERVER + '/v1/wzp/relay-config');
const data = await resp.json();
relayAddr = data.relay_addr;
token = data.token;
} catch(e) { addSys('Audio: cannot get relay config'); return; }
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true }
});
} catch(e) { addSys('Audio: mic access denied'); return; }
audioCtx = new AudioContext({ sampleRate: 48000 });
const host = relayAddr.replace(/^https?:\/\//, '');
const proto = host.startsWith('localhost') || host.startsWith('127.') ? 'ws:' : 'wss:';
const wsUrl = proto + '//' + host + '/ws/' + groupCallRoom;
audioWs = new WebSocket(wsUrl);
audioWs.binaryType = 'arraybuffer';
audioWs.onopen = async () => {
audioWs.send(JSON.stringify({ type: 'auth', token }));
addSys('Audio: connected to group call room');
const source = audioCtx.createMediaStreamSource(mediaStream);
const processor = audioCtx.createScriptProcessor(1024, 1, 1);
let captureBuffer = new Float32Array(0);
processor.onaudioprocess = (e) => {
if (!groupCallRoom || !audioWs || audioWs.readyState !== WebSocket.OPEN) return;
const input = e.inputBuffer.getChannelData(0);
const combined = new Float32Array(captureBuffer.length + input.length);
combined.set(captureBuffer);
combined.set(input, captureBuffer.length);
captureBuffer = combined;
while (captureBuffer.length >= 960) {
const frame = captureBuffer.slice(0, 960);
captureBuffer = captureBuffer.slice(960);
const pcm = new Int16Array(frame.length);
for (let i = 0; i < frame.length; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
}
audioWs.send(pcm.buffer);
}
};
source.connect(processor);
processor.connect(audioCtx.destination);
captureNode = processor;
};
audioWs.onmessage = (event) => {
if (!audioCtx || typeof event.data === 'string') return;
const pcm = new Int16Array(event.data);
if (pcm.length === 0) return;
const float32 = new Float32Array(pcm.length);
for (let i = 0; i < pcm.length; i++) float32[i] = pcm[i] / 32768.0;
const buffer = audioCtx.createBuffer(1, float32.length, 48000);
buffer.getChannelData(0).set(float32);
const src = audioCtx.createBufferSource();
src.buffer = buffer;
src.connect(audioCtx.destination);
src.start();
};
audioWs.onclose = () => { if (groupCallRoom) addSys('Audio: group call disconnected'); };
audioWs.onerror = () => { addSys('Audio: group call connection error'); };
}
function leaveGroupCall() {
if (!groupCallGroup) return;
const gname = groupCallGroup;
// Notify others via signal endpoint
const msg = JSON.stringify({ type: 'group_call', action: 'left', group: gname, room: groupCallRoom, from: normFP(myFingerprint) });
fetch(SERVER + '/v1/groups/' + gname + '/signal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: msg
}).catch(() => {});
stopAudio();
addSys('Left group call in #' + gname);
groupCallRoom = null;
groupCallGroup = null;
groupCallParticipants = [];
updateGroupCallUI();
// Reset call button
const btnCall = document.getElementById('btn-call');
btnCall.textContent = '\u{1F4DE}';
btnCall.onclick = () => startCall();
}
function handleGroupCallSignal(data) {
// Check if notifications are muted for this group
const muted = JSON.parse(localStorage.getItem('wz-gcall-mute') || '{}');
switch(data.action) {
case 'started':
if (data.from === normFP(myFingerprint)) return; // ignore own
if (!groupCallParticipants.includes(data.from)) groupCallParticipants = [data.from];
if (!muted[data.group]) {
addSys('\u{1F4DE} Group call started in #' + data.group + ' \u2014 type /gjoin to join');
sendCallNotification('Group Call', 'Call started in #' + data.group);
}
groupCallGroup = data.group;
groupCallRoom = data.room;
updateGroupCallUI();
break;
case 'joined':
if (data.from === normFP(myFingerprint)) return;
if (!groupCallParticipants.includes(data.from)) groupCallParticipants.push(data.from);
addSys(data.from.slice(0, 8) + '... joined #' + data.group + ' call (' + groupCallParticipants.length + ' participants)');
updateGroupCallUI();
break;
case 'left':
if (data.from === normFP(myFingerprint)) return;
groupCallParticipants = groupCallParticipants.filter(fp => fp !== data.from);
addSys(data.from.slice(0, 8) + '... left #' + data.group + ' call (' + groupCallParticipants.length + ' participants)');
if (groupCallParticipants.length === 0) {
addSys('Group call in #' + data.group + ' ended (no participants)');
if (groupCallRoom) leaveGroupCall();
}
updateGroupCallUI();
break;
}
}
function updateGroupCallUI() {
const bar = document.getElementById('call-bar');
const status = document.getElementById('call-status');
const btnCall = document.getElementById('btn-call');
const btnHangup = document.getElementById('btn-hangup');
if (groupCallRoom && audioWs) {
// We're in a group call
bar.classList.add('active');
btnCall.style.display = 'none';
btnHangup.style.display = '';
document.getElementById('btn-accept').style.display = 'none';
document.getElementById('btn-reject').style.display = 'none';
status.textContent = '\u{1F50A} Group call #' + groupCallGroup + ' (' + groupCallParticipants.length + ' in call)';
status.className = 'call-status';
} else if (groupCallRoom && !audioWs) {
// Group call exists but we haven't joined
bar.classList.add('active');
btnCall.style.display = '';
btnCall.textContent = 'Join Call';
btnCall.onclick = () => joinGroupCall(groupCallGroup, groupCallRoom);
btnHangup.style.display = 'none';
document.getElementById('btn-accept').style.display = 'none';
document.getElementById('btn-reject').style.display = 'none';
status.textContent = '\u{1F4DE} Group call in #' + groupCallGroup + ' (' + groupCallParticipants.length + ' in call)';
status.className = 'call-status incoming-call';
}
// If no group call, let the regular updateCallUI handle it
}
function toggleGroupCallMute(gname) {
const muted = JSON.parse(localStorage.getItem('wz-gcall-mute') || '{}');
muted[gname] = !muted[gname];
localStorage.setItem('wz-gcall-mute', JSON.stringify(muted));
addSys('Group call notifications for #' + gname + ': ' + (muted[gname] ? 'muted' : 'unmuted'));
}
// ═══════════════════════════════════════════════
// SECTION: Command Handlers
// ═══════════════════════════════════════════════
@@ -1600,10 +2050,136 @@ async function doSend() {
return;
}
if (text === '/debug') { DEBUG = !DEBUG; addSys('Debug logging: ' + (DEBUG ? 'ON (check browser console)' : 'OFF')); return; }
if (text === '/myip' || text === '/whatsmyip' || text === '/ip') {
try {
const resp = await fetch(SERVER + '/v1/whoami');
const data = await resp.json();
addSys('Your IP: ' + data.ip + ' (' + data.version + ')');
if (data.behind_proxy && data.proxy) {
addSys(' Behind proxy: yes');
if (data.proxy.x_forwarded_for) addSys(' X-Forwarded-For: ' + data.proxy.x_forwarded_for);
if (data.proxy.x_real_ip) addSys(' X-Real-IP: ' + data.proxy.x_real_ip);
if (data.proxy.x_forwarded_proto) addSys(' Proto: ' + data.proxy.x_forwarded_proto);
if (data.proxy.x_forwarded_host) addSys(' Host: ' + data.proxy.x_forwarded_host);
if (data.proxy.via) addSys(' Via: ' + data.proxy.via);
}
if (data.direct) addSys(' Direct connection: ' + data.direct);
} catch(e) { addSys('Error: ' + e.message); }
return;
}
if (text === '/call') { startCall(); return; }
if (text === '/testcall' || text === '/testtone') {
addSys('Audio test: playing 440Hz tone for 3 seconds...');
try {
const ctx = new AudioContext({ sampleRate: 48000 });
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.value = 440;
osc.connect(ctx.destination);
osc.start();
setTimeout(() => {
osc.frequency.value = 880;
addSys('Audio test: 880Hz...');
}, 1000);
setTimeout(() => {
osc.frequency.value = 660;
addSys('Audio test: 660Hz...');
}, 2000);
setTimeout(() => {
osc.stop();
ctx.close();
addSys('Audio test: done. If you heard 3 tones, speaker works.');
}, 3000);
} catch(e) { addSys('Audio test failed: ' + e.message); }
return;
}
if (text === '/testecho') {
addSys('Echo test: speak into mic, you should hear yourself...');
addSys('Type /stopecho to stop.');
try {
const ctx = new AudioContext({ sampleRate: 48000 });
const stream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: 48000, channelCount: 1, echoCancellation: false, noiseSuppression: false }
});
const source = ctx.createMediaStreamSource(stream);
// Direct mic → speaker loopback (with small delay to avoid feedback)
const delay = ctx.createDelay(0.15);
delay.delayTime.value = 0.1;
source.connect(delay);
delay.connect(ctx.destination);
addSys('Echo active \u2014 mic \u2192 speaker (100ms delay)');
window._echoTest = { ctx, stream, source, delay };
} catch(e) { addSys('Echo test failed: ' + e.message); }
return;
}
if (text === '/stopecho') {
if (window._echoTest) {
window._echoTest.stream.getTracks().forEach(t => t.stop());
window._echoTest.source.disconnect();
window._echoTest.delay.disconnect();
window._echoTest.ctx.close();
window._echoTest = null;
addSys('Echo test stopped.');
} else { addSys('No echo test running.'); }
return;
}
if (text === '/testmic') {
addSys('Mic test: recording 3 seconds, then playback...');
try {
const ctx = new AudioContext({ sampleRate: 48000 });
const stream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true }
});
const source = ctx.createMediaStreamSource(stream);
const processor = ctx.createScriptProcessor(4096, 1, 1);
const recorded = [];
processor.onaudioprocess = (e) => {
recorded.push(new Float32Array(e.inputBuffer.getChannelData(0)));
};
source.connect(processor);
processor.connect(ctx.destination);
addSys('Recording... speak now');
setTimeout(() => {
processor.disconnect();
source.disconnect();
stream.getTracks().forEach(t => t.stop());
// Concatenate and play back
const total = recorded.reduce((s, a) => s + a.length, 0);
const buffer = ctx.createBuffer(1, total, 48000);
const channel = buffer.getChannelData(0);
let offset = 0;
for (const chunk of recorded) { channel.set(chunk, offset); offset += chunk.length; }
addSys('Playing back ' + (total / 48000).toFixed(1) + 's of audio...');
const playSource = ctx.createBufferSource();
playSource.buffer = buffer;
playSource.connect(ctx.destination);
playSource.start();
playSource.onended = () => { ctx.close(); addSys('Mic test done. If you heard yourself, mic + speaker work.'); };
}, 3000);
} catch(e) { addSys('Mic test failed: ' + e.message); }
return;
}
if (text === '/hangup' || text === '/end') { hangupCall(); return; }
if (text === '/accept') { acceptCall(); return; }
if (text === '/reject') { rejectCall(); return; }
if (text.startsWith('/audio-variant')) {
const parts = text.split(/\s+/);
if (parts.length < 2) {
addSys('Audio variant: ' + audioVariant);
addSys('Options: pure, hybrid, full, ws, ws-fec, ws-full');
return;
}
const v = parts[1].toLowerCase();
const valid = ['pure', 'hybrid', 'full', 'ws', 'ws-fec', 'ws-full'];
if (!valid.includes(v)) {
addSys('Unknown variant: ' + v + '. Options: ' + valid.join(', '));
return;
}
audioVariant = v;
localStorage.setItem('wz-audio-variant', v);
addSys('Audio variant set to: ' + v + ' (takes effect on next call)');
return;
}
if (text === '/reset') {
localStorage.clear();
addSys('localStorage cleared. Refresh the page to start fresh.');
@@ -1677,6 +2253,33 @@ async function doSend() {
else addSys('Alias @' + parts[0] + ' removed by admin');
return;
}
if (text === '/admin-calls') {
try {
const resp = await fetch(SERVER + '/v1/calls/active');
const data = await resp.json();
if (data.calls && data.calls.length > 0) {
addSys('Active calls (' + data.calls.length + '):');
data.calls.forEach(c => {
addSys(' ' + c.call_id.slice(0,8) + ' ' + c.caller_fp.slice(0,12) + ' \u2192 ' + c.callee_fp.slice(0,12) + ' [' + c.status + '] ' + (c.group_name ? '#' + c.group_name : 'DM'));
});
} else {
addSys('No active calls');
}
} catch(e) { addSys('Error: ' + e.message); }
return;
}
if (text === '/admin-help' || text === '/admin') {
addSys('Admin commands:');
addSys(' /admin-calls \u2014 list all active calls');
addSys(' /admin-unalias <a> <pw> \u2014 force-remove an alias');
addSys(' /bundleinfo \u2014 debug key bundle info');
addSys(' /sessions \u2014 list cached sessions');
addSys(' /selftest \u2014 run WASM self-test');
addSys(' /audio-variant [v] \u2014 set audio stack (pure/hybrid/full/ws/ws-fec/ws-full)');
addSys(' /debug \u2014 toggle debug mode');
addSys(' /reset \u2014 clear all local data');
return;
}
if (text.startsWith('/r ') || text.startsWith('/reply ')) {
const replyText = text.startsWith('/r ') ? text.slice(3) : text.slice(7);
if (!lastDmPeer) { addSys('No one to reply to'); return; }
@@ -1711,6 +2314,19 @@ async function doSend() {
updateCallUI();
return;
}
if (text === '/gcall') { startGroupCall(); return; }
if (text === '/gjoin') {
if (groupCallRoom) joinGroupCall(groupCallGroup, groupCallRoom);
else addSys('No active group call');
return;
}
if (text === '/gleave-call' || text === '/gleave-audio') { leaveGroupCall(); return; }
if (text.startsWith('/gmute')) {
const peer = $peerInput.value.trim();
if (peer && peer.startsWith('#')) toggleGroupCallMute(peer.replace('#',''));
else addSys('Switch to a group first');
return;
}
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }
if (text.startsWith('/gjoin ')) { await groupJoin(text.slice(7).trim()); return; }
if (text === '/gleave') {
@@ -1733,11 +2349,19 @@ async function doSend() {
const r = await fetch(SERVER+'/v1/groups/'+currentGroup+'/members');
const d = await r.json();
if (d.error) { addSys('Error: '+d.error); return; }
addSys('Members of #'+currentGroup+':');
for (const m of d.members) {
const a = m.alias ? '@'+m.alias : m.fingerprint.slice(0,12)+'...';
addSys(' '+a+(m.is_creator?' ★':''));
}
const onlineCount = d.online_count || d.members.filter(m => m.online).length;
addSys('Members of #'+currentGroup+' ('+onlineCount+'/'+d.members.length+' online):');
// Resolve ETH addresses for members without aliases
const ethPromises = d.members.map(m =>
m.alias ? Promise.resolve('@'+m.alias) :
fetch(SERVER+'/v1/resolve/'+m.fingerprint).then(r2=>r2.json()).then(rd=>rd.eth_address ? rd.eth_address.slice(0,12)+'...' : m.fingerprint.slice(0,12)+'...').catch(()=>m.fingerprint.slice(0,12)+'...')
);
const labels = await Promise.all(ethPromises);
d.members.forEach((m, i) => {
const status = m.online ? '\u{1F7E2}' : '\u26AB';
const isSelf = m.fingerprint === normFP(myFingerprint);
addSys(' '+status+' '+labels[i]+(m.is_creator?' ★':'')+(isSelf?' *':''));
});
return;
}
if (text === '/friend' || text === '/friends') {
@@ -1943,6 +2567,14 @@ window.addEventListener('beforeinstallprompt', e => {
addSys('Tip: install as app for fullscreen + notifications. Type /install');
});
// Request notification permission on first user interaction
if ('Notification' in window && Notification.permission === 'default') {
document.addEventListener('click', function reqNotif() {
Notification.requestPermission();
document.removeEventListener('click', reqNotif);
}, { once: true });
}
// Initialize WASM and auto-load
(async function() {
try {

View File

@@ -0,0 +1,12 @@
# Copy to .env and fill in values
# Cloudflare API token (Zone:DNS:Edit permission for manko.yoga)
# Also create cf_api_token.txt with the same token for Docker secrets
# echo "YOUR_TOKEN" > cf_api_token.txt
CF_API_TOKEN=
# DNS records to create:
# voip.manko.yoga → A 172.16.81.135 (dev)
# voip.manko.yoga → AAAA 2a0d:3344:692c:2500:14f2:5885:d73c:b0a1 (ipv6 test)
# voip.manko.yoga → A 63.250.54.239 (production)
# voip.manko.yoga → AAAA 2602:ff16:9:0:1:3d9:0:1 (production ipv6)

View File

@@ -0,0 +1,30 @@
{
email admin@manko.yoga
}
# Wildcard cert for all subdomains
*.voip.manko.yoga {
tls {
dns cloudflare {$CF_API_TOKEN}
}
reverse_proxy wzp-web:8080
}
# Main domain — featherChat server
voip.manko.yoga {
tls {
dns cloudflare {$CF_API_TOKEN}
}
handle_path /audio/* {
reverse_proxy wzp-web:8080
}
# WZP WASM module (needed by audio variants loaded from /audio/js/)
handle /audio-wasm/* {
uri strip_prefix /audio-wasm
reverse_proxy wzp-web:8080
}
reverse_proxy warzone-server:7700
}

View File

@@ -0,0 +1,42 @@
{
email admin@manko.yoga
}
# Wildcard cert for all variant subdomains
*.voip.manko.yoga {
tls {
dns cloudflare {$CF_API_TOKEN}
}
# Route each subdomain to wzp-web with the right variant
@v1 host v1.voip.manko.yoga
@v2 host v2.voip.manko.yoga
@v3 host v3.voip.manko.yoga
@v4 host v4.voip.manko.yoga
@v5 host v5.voip.manko.yoga
@v6 host v6.voip.manko.yoga
# Rewrite root path to include variant param
rewrite @v1 / /?variant=pure
rewrite @v2 / /?variant=hybrid
rewrite @v3 / /?variant=full
rewrite @v4 / /?variant=ws
rewrite @v5 / /?variant=ws-fec
rewrite @v6 / /?variant=ws-full
# All subdomains proxy to wzp-web
reverse_proxy wzp-web:8080
}
# Main domain — featherChat server
voip.manko.yoga {
tls {
dns cloudflare {$CF_API_TOKEN}
}
handle_path /audio/* {
reverse_proxy wzp-web:8080
}
reverse_proxy warzone-server:7700
}

View File

@@ -0,0 +1,12 @@
# Caddy with Cloudflare DNS plugin — builds for any arch
FROM caddy:2-builder AS builder
# Force IPv4-only for Go module downloads (Docker build may lack IPv6)
ENV GOFLAGS="-mod=mod"
RUN echo 'precedence ::ffff:0:0/96 100' > /etc/gai.conf && \
xcaddy build \
--with github.com/caddy-dns/cloudflare
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

View File

@@ -0,0 +1,30 @@
# featherChat server — multi-stage build
# Build context: featherChat repo root (../../..)
FROM rust:latest AS builder
WORKDIR /build
# Copy warzone workspace
COPY warzone/Cargo.toml warzone/Cargo.lock ./warzone/
COPY warzone/crates ./warzone/crates
WORKDIR /build/warzone
# Build WASM first (server embeds it via include_str!/include_bytes!)
RUN cargo install wasm-pack && \
wasm-pack build crates/warzone-wasm --target web --out-dir /build/warzone/wasm-pkg 2>&1 || true
# Build server (now wasm-pkg exists at the expected relative path)
RUN cargo build --release --bin warzone-server
# Runtime
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/warzone/target/release/warzone-server /usr/local/bin/
WORKDIR /data
EXPOSE 7700
ENTRYPOINT ["warzone-server"]
CMD ["--bind", "0.0.0.0:7700"]

View File

@@ -0,0 +1,30 @@
# WZP relay + web bridge — multi-stage build
# Build context: featherChat repo root (../../..)
FROM rust:latest AS builder
RUN apt-get update && apt-get install -y cmake pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy warzone-phone workspace (feature/wzp-web-variants branch)
COPY warzone-phone/Cargo.toml warzone-phone/Cargo.lock ./warzone-phone/
COPY warzone-phone/crates ./warzone-phone/crates
# wzp-crypto depends on warzone-protocol via deps/featherchat/warzone/...
COPY warzone/crates/warzone-protocol ./warzone-phone/deps/featherchat/warzone/crates/warzone-protocol
# Build both binaries
WORKDIR /build/warzone-phone
RUN cargo build --release --bin wzp-relay --bin wzp-web
# Runtime — use same distro as builder to match glibc
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/warzone-phone/target/release/wzp-relay /usr/local/bin/
COPY --from=builder /build/warzone-phone/target/release/wzp-web /usr/local/bin/
# Copy static files for wzp-web (HTML, JS, WASM)
COPY --from=builder /build/warzone-phone/crates/wzp-web/static /data/static
WORKDIR /data

View File

@@ -0,0 +1,24 @@
# IPv6 overlay — use with:
# docker compose -f docker-compose.yml -f docker-compose.ipv6.yml up -d
#
# Requires Docker daemon IPv6 support:
# /etc/docker/daemon.json: {"ipv6": true, "fixed-cidr-v6": "fd00::/80"}
services:
caddy:
ports:
- "[::]:80:80"
- "[::]:443:443"
- "[::]:443:443/udp"
networks:
frontend:
enable_ipv6: true
ipam:
config:
- subnet: fd00:cafe:1::/64
backend:
enable_ipv6: true
ipam:
config:
- subnet: fd00:cafe:2::/64

View File

@@ -0,0 +1,97 @@
# featherChat + WZP full stack
# Usage:
# echo "YOUR_CF_API_TOKEN" > cf_api_token.txt
# docker compose up -d
#
# DNS: voip.manko.yoga → your IP
# Test: https://voip.manko.yoga
services:
# ─── Caddy reverse proxy (TLS termination) ───
caddy:
build:
context: .
dockerfile: Dockerfile.caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (QUIC)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
secrets:
- cf_api_token
entrypoint: ["/bin/sh", "-c", "export CF_API_TOKEN=$(cat /run/secrets/cf_api_token) && caddy run --config /etc/caddy/Caddyfile --adapter caddyfile"]
depends_on:
- warzone-server
- wzp-web
networks:
- frontend
- backend
# ─── featherChat server ───
warzone-server:
build:
context: ../../..
dockerfile: warzone/deploy/docker/Dockerfile.server
restart: unless-stopped
environment:
WZP_RELAY_ADDR: "voip.manko.yoga/audio"
RUST_LOG: "info"
volumes:
- server_data:/data
command: ["--bind", "0.0.0.0:7700", "--enable-bots"]
networks:
- backend
# ─── WZP QUIC relay (audio SFU) ───
wzp-relay:
build:
context: ../../..
dockerfile: warzone/deploy/docker/Dockerfile.wzp
restart: unless-stopped
entrypoint: ["wzp-relay"]
command:
- "--listen"
- "0.0.0.0:4433"
networks:
backend:
ipv4_address: 172.28.0.10
# ─── WZP web bridge (browser WS ↔ QUIC relay) ───
# No --tls (Caddy handles TLS), no --auth-url (Caddy terminates)
# Variants: ?variant=pure|hybrid|full
wzp-web:
build:
context: ../../..
dockerfile: warzone/deploy/docker/Dockerfile.wzp
restart: unless-stopped
entrypoint: ["wzp-web"]
command:
- "--port"
- "8080"
- "--relay"
- "172.28.0.10:4433"
depends_on:
- wzp-relay
networks:
- backend
secrets:
cf_api_token:
file: ./cf_api_token.txt
volumes:
caddy_data:
caddy_config:
server_data:
networks:
frontend:
backend:
ipam:
config:
- subnet: 172.28.0.0/24

View File

@@ -0,0 +1,58 @@
#!/bin/bash
set -e
HOST="${1:-voip.manko.yoga}"
SCHEME="${2:-https}"
echo "=== featherChat Stack Test ==="
echo "Host: $HOST ($SCHEME)"
echo ""
# 1. Web UI
echo -n "1. Web UI (GET /)... "
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/")
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "FAIL ($STATUS)"
# 2. API health
echo -n "2. API health... "
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/v1/health")
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "FAIL ($STATUS)"
# 3. WASM module
echo -n "3. WASM module... "
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/wasm/warzone_wasm.js")
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "FAIL ($STATUS)"
# 4. WZP relay config
echo -n "4. WZP relay config... "
RELAY=$(curl -s "$SCHEME://$HOST/v1/wzp/relay-config")
echo "$RELAY" | grep -q "relay_addr" && echo "OK ($(echo $RELAY | python3 -c 'import sys,json; print(json.load(sys.stdin).get("relay_addr","?"))' 2>/dev/null))" || echo "FAIL"
# 5. Audio bridge (wzp-web via Caddy /audio path)
echo -n "5. Audio bridge (GET /audio/)... "
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SCHEME://$HOST/audio/")
# wzp-web returns 200 for its landing page
[ "$STATUS" = "200" ] && echo "OK ($STATUS)" || echo "WARN ($STATUS — wzp-web may not serve GET /)"
# 6. WebSocket upgrade test
echo -n "6. WS upgrade test... "
WS_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Upgrade: websocket" -H "Connection: Upgrade" "$SCHEME://$HOST/v1/ws/test")
echo "($WS_STATUS)"
# 7. TLS cert check
if [ "$SCHEME" = "https" ]; then
echo -n "7. TLS cert... "
ISSUER=$(echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null)
echo "$ISSUER" | grep -q "Let's Encrypt\|Cloudflare\|R3\|E1" && echo "OK ($ISSUER)" || echo "$ISSUER"
fi
# 8. IPv6 test
echo -n "8. IPv6... "
if curl -6 -s -o /dev/null -w "%{http_code}" --connect-timeout 3 "$SCHEME://$HOST/" 2>/dev/null; then
echo " (IPv6 reachable)"
else
echo "not available (IPv4 only)"
fi
echo ""
echo "=== Done ==="

View File

@@ -0,0 +1,98 @@
#!/bin/bash
# Updates voip.manko.yoga DNS records with current public IPs.
# Usage:
# ./update-dns.sh Loop every 5 minutes
# ./update-dns.sh --once Run once and exit
#
# Reads CF_API_TOKEN env var or deploy/docker/cf_api_token.txt
DOMAIN="voip.manko.yoga"
ZONE="manko.yoga"
INTERVAL="${DNS_UPDATE_INTERVAL:-300}"
get_token() {
if [ -n "${CF_API_TOKEN:-}" ]; then
echo "$CF_API_TOKEN"
elif [ -f /run/secrets/cf_api_token ]; then
cat /run/secrets/cf_api_token | tr -d '\n'
else
echo "ERROR: no CF token" >&2
exit 1
fi
}
get_zone_id() {
curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$ZONE" \
-H "Authorization: Bearer $(get_token)" | \
python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])" 2>/dev/null
}
get_public_ipv4() {
curl -4 -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || \
curl -4 -s --connect-timeout 5 https://ifconfig.me 2>/dev/null || echo ""
}
get_public_ipv6() {
curl -6 -s --connect-timeout 5 https://api6.ipify.org 2>/dev/null || \
curl -6 -s --connect-timeout 5 https://ifconfig.co 2>/dev/null || echo ""
}
upsert_record() {
local zone_id="$1" type="$2" content="$3" token
token=$(get_token)
local existing
existing=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
-H "Authorization: Bearer $token")
local rec_id current
rec_id=$(echo "$existing" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null)
current=$(echo "$existing" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['content'] if r else '')" 2>/dev/null)
if [ "$current" = "$content" ]; then
echo " $type: $content (unchanged)"
return
fi
if [ -n "$rec_id" ]; then
curl -4 -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " $type: $current -> $content (updated)"
else
curl -4 -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " $type: $content (created)"
fi
}
update() {
echo "[$(date -u +%H:%M:%S)] Updating DNS for $DOMAIN..."
local zone_id
zone_id=$(get_zone_id)
if [ -z "$zone_id" ]; then
echo " ERROR: cannot get zone ID"
return 1
fi
local ipv4 ipv6
ipv4=$(get_public_ipv4)
ipv6=$(get_public_ipv6)
[ -n "$ipv4" ] && upsert_record "$zone_id" "A" "$ipv4" || echo " A: no IPv4"
[ -n "$ipv6" ] && upsert_record "$zone_id" "AAAA" "$ipv6" || echo " AAAA: no IPv6"
}
# Main
if [ "${1:-}" = "--once" ]; then
update
else
update
while true; do
sleep "$INTERVAL"
update
done
fi

View File

@@ -1,7 +1,9 @@
# Warzone Messenger (featherChat) — Architecture
**Version:** 0.0.21
**Status:** Phase 1 + Phase 2 + WZP Integration + Federation
**Version:** 0.0.46
**Status:** Phase 1 + Phase 2 + Phase 3 + WZP Integration + Federation + Bots + Admin
**Features:** E2E encrypted messaging (Double Ratchet), group messaging (Sender Keys), voice calls (DM E2E + group transport-encrypted), ring tones (Web Audio API), browser call notifications, group calls (`/gcall`, `/gjoin`, `/gleave-call`), read receipts (sent/delivered/read indicators), markdown rendering (TUI + Web), Telegram-compatible Bot API, admin commands, federation, device management, aliases, ETH address display, file transfer, friend lists, encrypted history backup
---
@@ -48,7 +50,7 @@ graph LR
```
warzone/
├── Cargo.toml # Workspace root (v0.0.21)
├── Cargo.toml # Workspace root (v0.0.46)
├── federation.example.json # Federation config template
├── crates/
│ ├── warzone-protocol/ # Core crypto & message types
@@ -227,6 +229,7 @@ Auth-Protected (bearer token required):
POST /v1/keys/register|replenish
POST /v1/calls/initiate|:id/end
POST /v1/groups/:name/call Group call initiation
POST /v1/groups/:name/signal Group call signal broadcast
POST /v1/devices/:id/kick Kick a device
POST /v1/devices/revoke-all Panic button
POST /v1/presence/batch Bulk online check
@@ -428,9 +431,23 @@ sequenceDiagram
| `GET /v1/calls/active` | List active calls |
| `POST /v1/calls/missed` | Get & clear missed calls |
| `POST /v1/groups/:name/call` | Group call (fan-out to members) |
| `POST /v1/groups/:name/signal` | Broadcast call signal to group members |
| `GET /v1/presence/:fp` | Check if peer is online |
| `GET /v1/wzp/relay-config` | Get relay address + service token |
### Ring Tones
- **Incoming call:** Web Audio API oscillator playing a 440/480 Hz dual-tone pattern (classic North American ring cadence)
- **Outgoing ringback:** 2 seconds on / 4 seconds off pattern until callee answers or rejects
- **Browser notifications:** If the web client tab is in background, an incoming call triggers a system notification so the user does not miss it
### Group Calls
- `/gcall <group>` starts a group call room; `/gjoin <group>` joins an existing room; `/gleave-call` leaves
- Group call signals are broadcast via `POST /v1/groups/:name/signal` (fan-out to all online members)
- Room naming convention: DM calls use a sorted fingerprint pair as room ID; group calls use `gc-<groupname>`
- **Encryption:** Group calls are transport-encrypted only (QUIC with TLS). They are NOT end-to-end encrypted. MLS (RFC 9420) key agreement for group call media is on the roadmap.
### Group Call Room ID
```
@@ -482,12 +499,13 @@ sequenceDiagram
S->>U: Deliver reply via WS
```
- Bots register with a fingerprint and get a token
- Bot aliases must end with `Bot`, `bot`, or `_bot` (enforced)
- Non-bot users cannot register reserved aliases
- `getUpdates` returns Telegram-compatible Update objects
- `sendMessage` delivers plaintext (no E2E in v1)
- **BotFather** creates bots and issues tokens; each bot gets an auto-registered alias
- Bot aliases must end with `Bot`, `bot`, or `_bot` (enforced); non-bot users cannot register reserved aliases
- **Per-bot numeric ID mapping:** Each user is assigned a unique numeric ID per bot, preventing cross-bot user correlation (privacy)
- **Telegram-compatible endpoints:** `getUpdates` (long-poll), `sendMessage`, `editMessage`, `sendDocument`, inline keyboards
- `sendMessage` delivers plaintext (no E2E in v1 — bot messages are not encrypted)
- Messages from users arrive as encrypted blobs (base64) or plaintext bot messages
- **System bots:** Configured via `--bots-config <file>` on server startup; auto-created on first run
### Addressing
@@ -519,6 +537,8 @@ ETH↔fingerprint mapping stored on key registration.
| Inter-server | Authenticated | SHA-256(secret \|\| body) token |
| WS connections | Rate-limited | 5 per fingerprint, 200 global |
| WZP relay | Token-gated | featherChat bearer token validation |
| DM calls (voice) | E2E encrypted | ChaCha20-Poly1305 over QUIC via WZP relay |
| Group calls (voice) | Transport-encrypted only | QUIC/TLS — NOT E2E (MLS on roadmap) |
### What's NOT Protected (Phase 1 scope)
@@ -587,11 +607,14 @@ graph TB
| Crate | Tests | Coverage |
|-------|------:|---------|
| warzone-protocol | 34 | X3DH, Double Ratchet, Sender Keys, AEAD, HKDF, identity, ethereum, prekeys, mnemonic, friend list, x3dh web client |
| warzone-protocol | 39 | X3DH, Double Ratchet, Sender Keys, AEAD, HKDF, identity, ethereum, prekeys, mnemonic, friend list, x3dh web client, receipts |
| warzone-client (types) | 10 | App init, scroll, connected, timestamps, normfp |
| warzone-client (input) | 25 | Text editing, cursor movement, scroll keys, quit |
| warzone-client (draw) | 9 | Rendering, timestamps, connection dot, scroll, unread badge |
| **Total** | **122** | All passing |
| warzone-client (draw) | 13 | Rendering, timestamps, connection dot, scroll, unread badge, markdown |
| warzone-server (integration) | 10 | Route handlers, auth middleware, group ops, call state |
| warzone-server (bin) | 10 | CLI args, startup, federation init, bot config |
| Other (e2e, misc) | 48 | Client-side E2E flows, file transfer, admin commands |
| **Total** | **155** | All passing |
WZP side: 15 cross-project identity tests + 17 integration tests (separate repo).
@@ -667,6 +690,46 @@ sequenceDiagram
---
## Admin Commands
| Command | Purpose |
|---------|---------|
| `/admin-calls` | List all currently active calls on the server |
| `/admin-unalias <alias> <pw>` | Force-remove an alias (requires admin password) |
| `/admin-help` | Show available admin commands |
Admin commands are available in the TUI client and are authenticated server-side.
---
## Read Receipts
- **TUI:** Tracks which messages are visible in the viewport and sends `Receipt::Read` back to the sender when a message scrolls into view
- **Web:** Sender sees delivery indicators: single check mark (sent) then double check mark (delivered) then blue double check mark (read)
- **Deduplication:** Each message is receipted only once; the client tracks which message IDs have already been acknowledged to avoid redundant receipt traffic
---
## Markdown Rendering
- **TUI:** Custom `md_to_spans` parser converts markdown to ratatui `Span` objects supporting bold, italic, inline code, headers, blockquotes, and lists
- **Web:** `renderMd()` function in the embedded JS handles code blocks, inline code, bold, italic, headers, links, blockquotes, and ordered/unordered lists
- Both renderers are deliberately simple (no AST) to avoid pulling in heavy markdown dependencies
---
## Known Issues and Limitations
| Issue | Details |
|-------|---------|
| Group call signal delivery | Depends on members being online; there is no offline queue for call signals |
| TUI voice calls | Require the web client; no native audio (cpal) integration yet |
| Bot messages are plaintext | v1 limitation; bots cannot participate in E2E encryption |
| `/gmembers` ETH resolution | Async resolution may briefly show the raw fingerprint before the ETH address loads |
| Service worker cache staleness | Cache version in `web.rs` must be bumped on every change or browsers will serve stale WASM/JS content |
---
## Extensibility
### Adding New WireMessage Variants

View File

@@ -1,6 +1,6 @@
# Warzone Client -- Operation Guide
**Version:** 0.0.21
**Version:** 0.0.46
---
@@ -289,6 +289,48 @@ When decryption fails on an incoming message, the TUI automatically:
The next incoming `KeyExchange` from that peer will create a fresh session
without manual intervention.
### Voice Calls
The TUI supports DM and group call commands:
| Command | Description |
|---------|-------------|
| `/call [peer]` | Initiate a voice call with the current or specified peer |
| `/accept` | Accept an incoming call |
| `/reject` | Reject an incoming call |
| `/hangup` | End the current call |
**Call state display:** The TUI header bar shows call status with color coding:
- **Yellow "CALLING..."** — outgoing call ringing, waiting for peer to accept
- **Green "IN CALL" + timer** — active call with elapsed duration (MM:SS)
- No indicator when idle
**Note:** TUI audio requires the web client. When a call is active in the TUI, a hint is displayed directing the user to open the web client for actual audio. The TUI handles signaling (offer/answer/ICE) but does not capture or play audio.
### Read Receipts
Read receipts track message delivery through three states: sent, delivered, and read.
- **Sender fingerprint tracking:** Each outgoing message records the sender's fingerprint so the system can match incoming receipts to the correct message.
- **Dedup set:** A per-conversation set prevents sending duplicate read receipts for the same message. Once a read receipt is sent for a message ID, it is not sent again.
- **Viewport-based:** Read receipts are triggered when a message scrolls into the visible area of the chat. Messages that are never scrolled into view do not generate read receipts.
### Markdown Rendering
Messages support inline markdown formatting via the `md_to_spans` function, which converts markdown syntax into ratatui `Span` elements with appropriate styling:
| Syntax | TUI Rendering |
|--------|---------------|
| `**bold**` | Bold attribute |
| `*italic*` | Italic attribute |
| `` `code` `` | Dark gray background, monospace feel |
| `# Header` | Bold + uppercase (line start only) |
| `> quote` | Italic + gray foreground (line start only) |
| `- list item` | Bullet prefix (line start only) |
Markdown is parsed per-message at render time. The web client renders the same syntax as HTML elements.
---
## 5. Full Command Reference

View File

@@ -253,9 +253,30 @@ The bridge translates numeric chat_id ↔ fingerprints automatically.
| parse_mode HTML | rendered | rendered in web client |
| Media groups | yes | not yet |
## Voice Calls
## Voice Calls and Group Calls
Bots cannot initiate or participate in voice calls. Voice is peer-to-peer only between human clients (web or TUI). Call signaling messages (`CallSignal` type) are delivered to bots via getUpdates as `text="/call_Offer"` etc., but bots should ignore them -- there is no audio path for bots.
Bots cannot initiate or participate in voice calls or group calls. Voice is peer-to-peer only between human clients (web or TUI). Call signaling messages (`CallSignal` type) are delivered to bots via getUpdates as `text="/call_Offer"` etc., but bots should ignore them -- there is no audio path for bots. Group call signals (`/gcall`, `/gjoin`, etc.) are similarly not actionable by bots.
## Markdown Rendering
Bot replies support inline markdown formatting in both the web and TUI clients:
- `**bold**` or `<b>bold</b>` (with `parse_mode: "HTML"`)
- `*italic*` or `<i>italic</i>`
- `` `inline code` `` or `<code>code</code>`
- `[link text](url)` or `<a href="url">text</a>`
- ` ```block``` ` for code blocks
When using `parse_mode: "HTML"`, the HTML tags are rendered. Without `parse_mode`, the web client renders markdown syntax natively. Both paths produce styled output.
## Per-Bot Numeric IDs
Each bot sees a unique numeric ID for each user (`from.id` in updates). These IDs are:
- Deterministic: the same user always maps to the same numeric ID for a given bot
- Per-bot unique: different bots see different numeric IDs for the same user
- Privacy-preserving: bots cannot correlate users across bots or recover raw fingerprints from the numeric ID
- Derived via HMAC of the user's fingerprint keyed with the bot's token prefix
Use `from.id` (or `chat.id`) as-is for replies. Do not attempt to reverse it to a fingerprint.
## Key Rules

View File

@@ -30,6 +30,17 @@ cmd | action | example
/gleave | leave current group | /gleave
/gkick <fp> | kick member (creator only) | /gkick abc123
/gmembers | list group members + status | /gmembers
/call | start voice call with current peer | /call
/call <addr> | start voice call with specific peer | /call @alice
/accept | accept incoming call | /accept
/reject | reject incoming call | /reject
/hangup | end current call | /hangup
/gcall | start group voice call in current group | /gcall
/gjoin | join active group call | /gjoin
/gleave-call | leave group call (stay in group) | /gleave-call
/gmute | toggle mute in group call | /gmute
/admin-calls | list active calls on server (admin) | /admin-calls
/admin-help | show admin commands (admin) | /admin-help
/file <path> | send file (max 10MB, 64KB chunks) | /file ./doc.pdf
/quit, /q | exit | /q
@@ -229,6 +240,46 @@ cmd | action | example
/reject | reject incoming call | /reject
/hangup | end current call | /hangup
### Relay Config Flow
1. Client calls `GET /v1/wzp/relay-config` with bearer token
2. Server validates auth, issues a short-lived WZP token
3. Response: `{"relay_addr":"host:port","token":"..."}`
4. Client opens WebSocket to `ws://relay_addr` with the WZP token
5. Audio frames flow over the WebSocket via the wzp-web bridge
### Ring Tones
Ring tones play automatically using the Web Audio API (oscillator-based, no audio files):
- **Outgoing call**: caller hears a ringback tone (repeating double beep) while waiting for answer
- **Incoming call**: callee hears a ringing tone (classic ring pattern) until they accept/reject
- Both tones stop immediately on answer, reject, or hangup
- TUI clients receive a terminal bell on incoming call (no audio playback)
### Group Calls
Group voice calls use the same WZP relay infrastructure but with room-based routing:
```
Members A,B,C <--WS--> wzp-web <--QUIC--> wzp-relay (room: group:<group_name>)
```
- `/gcall` signals all group members via the group signal endpoint (`POST /v1/groups/:name/signal`)
- Room name format: `group:<group_name>` (e.g., `group:ops`)
- Any member can `/gjoin` an active group call
- `/gleave-call` leaves the audio room but stays in the text group
- `/gmute` toggles local mic mute (no server-side mixing)
- Group calls are transport-encrypted only; MLS (RFC 9420) E2E planned
### Admin Commands
cmd | action | example
--- | --- | ---
/admin-calls | show all active calls on the server | /admin-calls
/admin-help | list available admin commands | /admin-help
Admin commands require server-side admin privilege (configured per-fingerprint).
## Server API (other endpoints)
- POST /v1/register -- upload prekey bundle

View File

@@ -1,7 +1,7 @@
# Warzone Messenger (featherChat) — Progress Report
**Current Version:** 0.0.21
**Last Updated:** 2026-03-28
**Current Version:** 0.0.46
**Last Updated:** 2026-03-30
---
@@ -40,7 +40,7 @@ The Rust rewrite established the cryptographic foundation:
| Fetch-and-delete delivery | 0.0.7 | Done |
| Aliases with TTL, recovery keys | 0.0.10 | Done |
| 17 protocol tests | 0.0.10 | Done |
| CLI Web interop verified | 0.0.10 | Done |
| CLI <-> Web interop verified | 0.0.10 | Done |
### Phase 2 — Core Messaging
@@ -94,15 +94,41 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
---
## Current Version: v0.0.21
## Version History
| Version | Date | Highlights |
|---------|------|------------|
| 0.0.22 | 2026-03-28 | ETH identity in web client |
| 0.0.23-24 | 2026-03-28 | ETH display everywhere (TUI + Web) |
| 0.0.25-26 | 2026-03-28 | Federation persistent WS, text selection |
| 0.0.27-29 | 2026-03-29 | Bot API: BotFather, getUpdates, sendMessage |
| 0.0.30-31 | 2026-03-29 | Bot numeric IDs, inline keyboards |
| 0.0.32-33 | 2026-03-29 | System bots config, version bump |
| 0.0.34 | 2026-03-29 | Bot sendMessage fix, per-bot ID mapping |
| 0.0.35 | 2026-03-29 | WASM create_call_signal, selectable identity |
| 0.0.36 | 2026-03-29 | Web call UI (call/accept/reject/hangup) |
| 0.0.37 | 2026-03-29 | TUI call state UI, missed calls, inline keyboards |
| 0.0.38 | 2026-03-29 | Session versioning, wire envelope, auto-backup |
| 0.0.39 | 2026-03-30 | Contacts online, message wrap, tab complete, OTPK |
| 0.0.40 | 2026-03-30 | Call reload, ETH cache prefill, 10 server tests |
| 0.0.41 | 2026-03-30 | Read receipts (viewport tracking) |
| 0.0.42 | 2026-03-30 | Markdown rendering in TUI messages |
| 0.0.43 | 2026-03-30 | Voice calls via WZP audio bridge |
| 0.0.44 | 2026-03-30 | Web UI polish, ETH display, call routing fixes |
| 0.0.45 | 2026-03-30 | Call ring tones + group calls |
| 0.0.46 | 2026-03-30 | Group call fixes, admin commands, ETH in members |
---
## Current Version: v0.0.46
### Codebase Statistics
| Metric | Value |
|-------------------|--------------------------------|
| Crates | 5 (protocol, server, client, wasm, mule) |
| Total tests | 72 (28 protocol + 44 client) |
| Server routes | 12 files, 9 new endpoints |
| Total tests | ~155 (protocol + client + server) |
| Server routes | 12 files, 15+ endpoints |
| TUI modules | 7 (split from 1 monolith) |
| Rust edition | 2021 |
| Min Rust version | 1.75 |
@@ -133,21 +159,29 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
- Group messaging with Sender Keys
- WebSocket real-time delivery + offline queue
- File transfer (up to 10 MB, chunked, SHA-256 verified)
- Delivery and read receipts
- Delivery and read receipts (viewport tracking)
- TUI client with full command set
- Web client (WASM) with identical crypto
- Alias system with TTL, recovery, admin
- Challenge-response authentication
- Ethereum address derivation from same seed
- Encrypted backup and restore
- Ethereum address derivation from same seed (displayed in TUI + Web)
- Encrypted backup and restore (with auto-backup)
- Contact list and message history
- Multi-device support (basic)
- Bot API with BotFather (Telegram-compatible)
- Voice calls (1:1 via WZP, Web audio bridge)
- Group calls (transport-encrypted, fan-out signaling)
- Call ring tones (Web Audio API oscillators)
- Markdown rendering in TUI + Web messages
- Federation with persistent WebSocket
- Admin commands
- Session state versioning + wire envelope format
---
## Test Suite
72 tests across protocol + client crates:
~155 tests across protocol + client + server crates:
### Protocol Tests (28)
@@ -171,6 +205,12 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
| 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 |
### Server Tests (10+)
| Area | Tests | Coverage |
|---------------|-------|---------------------------------------------|
| integration | 10+ | Call reload, ETH cache, presence, routing |
---
## Bugs Fixed
@@ -184,91 +224,58 @@ Built on the Phase 1 foundation to deliver a complete messaging experience:
| Dedup overflow | 0.0.16 | Dedup tracker grew unbounded. Fixed with FIFO eviction at 10,000 entries. |
| Alias normalization | 0.0.18 | Fingerprints with colons caused lookup failures. Added `normalize_fp()` to strip non-hex characters. |
| Receipt routing | 0.0.12 | Receipts sent to wrong fingerprint when switching peers in TUI. Fixed by including correct sender_fingerprint in Receipt wire messages. |
| Lookbehind regex | 0.0.42 | JS lookbehind regex broke Safari markdown rendering. Replaced with forward-compatible pattern. |
| Resolve parens warning | 0.0.43 | Unnecessary parentheses in resolve.rs caused compiler warning. Removed. |
---
## Known Issues and Limitations
### Current Limitations
### Known Issues
1. **No perfect forward secrecy in groups:** Sender Keys provide forward secrecy within a chain but not per-message PFS like Double Ratchet. Acceptable for groups under 50 members.
1. **Group call signals only reach online members:** Offline members do not receive group call join signals. They must be online when the call starts.
2. **No sealed sender:** The server sees sender and recipient fingerprints in message routing metadata. Planned for Phase 6.
2. **TUI voice needs web client:** The TUI cannot capture/play audio natively; voice calls require the web client with WZP audio bridge. TUI voice via cpal is planned (FC-P7-T1).
3. **No server-at-rest encryption:** The sled database on the server is unencrypted. Message content is E2E encrypted, but metadata (fingerprints, timestamps, group membership) is visible to the server operator.
3. **Bot messages are plaintext:** Bot API messages are not E2E encrypted (v1 design decision). Bots see and send cleartext.
4. **Auth tokens in memory:** Challenge-response tokens are partially stored in memory (challenges are in a static HashMap). Production deployment should use the DB for all auth state.
4. **Group calls are transport-encrypted only:** Group call audio is encrypted by QUIC on the wire but the WZP relay can see plaintext audio. MLS E2E encryption is planned (FC-P5-T5).
5. **No rate limiting:** No protection against message flooding or registration spam. Planned for Phase 7.
5. **Service worker cache must be bumped:** After WASM changes, the `wz-vN` cache version in web.rs must be incremented or browsers serve stale code.
6. **Single server only:** No federation between servers yet. Planned for Phase 3.
### Existing Limitations
7. **No push notifications:** Users must keep a WebSocket connection open or poll. ntfy integration planned for Phase 7.
6. **No perfect forward secrecy in groups:** Sender Keys provide forward secrecy within a chain but not per-message PFS like Double Ratchet. Acceptable for groups under 50 members.
8. **Web client: no OTPKs:** The web client does not generate one-time pre-keys (cannot reliably store secrets). X3DH works without DH4, but replay protection is slightly weaker.
7. **No sealed sender:** The server sees sender and recipient fingerprints in message routing metadata.
9. **Web client: localStorage only:** Seed and session data stored in browser localStorage. Clearing browser data = lost identity.
8. **No server-at-rest encryption:** The sled database on the server is unencrypted. Message content is E2E encrypted, but metadata (fingerprints, timestamps, group membership) is visible to the server operator.
10. **No message ordering guarantees:** Messages may arrive out of order. The Double Ratchet handles this for decryption, but the UI does not reorder displayed messages.
9. **Auth tokens in memory:** Challenge-response tokens are partially stored in memory (challenges are in a static HashMap). Production deployment should use the DB for all auth state.
10. **Single server only:** No full federation between servers yet. Persistent WS relay exists but full DNS discovery is planned.
11. **No push notifications:** Users must keep a WebSocket connection open or poll.
12. **Web client: no OTPKs:** The web client does not generate one-time pre-keys (cannot reliably store secrets). X3DH works without DH4, but replay protection is slightly weaker.
13. **Web client: localStorage only:** Seed and session data stored in browser localStorage. Clearing browser data = lost identity.
14. **No message ordering guarantees:** Messages may arrive out of order. The Double Ratchet handles this for decryption, but the UI does not reorder displayed messages.
---
## Roadmap: What's Next
### Phase 3 — Federation & Key Transparency (next priority)
### Priority Order (Updated v0.0.46)
- DNS TXT record format for server discovery
- User self-signed key publication to DNS
- Key verification: server vs DNS cross-check
- Server-to-server mutual TLS
- Federated message delivery
- Server key pinning (TOFU)
- Gossip-based peer discovery
### Phase 4 — Warzone Delivery
- Mule protocol specification and implementation
- Mule authentication and authorization
- Message pickup with capacity declaration
- Delivery receipt enforcement
- Outer encryption layer (hide metadata from mule)
- Bundle compression (zstd)
- Mule CLI binary
### Phase 5 — Transport Fallbacks
- Bluetooth mule transfer (phone-to-phone)
- LoRa transport layer (compact binary format)
- mDNS / LAN discovery for local mesh
- Wi-Fi Direct for nearby device sync
### Phase 6 — Metadata Protection
- Sealed sender (server doesn't know the sender)
- Onion routing between federated servers (opt-in)
- Padding and traffic shaping
- Traffic analysis resistance
### Phase 7 — Polish & Operations
- ntfy push notification integration
- DNS-over-HTTPS for censored networks
- Admin CLI for server management
- Rate limiting and abuse prevention
- Monitoring and health checks
- Audit logging
- Server-at-rest encryption (optional `--encrypt-db` flag)
- Cross-compilation CI (Linux x86/ARM, macOS, Windows, WASM)
- PWA: service worker, offline shell, install prompt
### Priority Order (Updated v0.0.21)
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
1. **TUI voice via cpal (FC-P7-T1)** — native audio capture/playback
2. **Web extract (FC-P3-T5)** — extract web.rs monolith into separate files
3. **MLS group E2E (FC-P5-T5)** — RFC 9420 for group call encryption
4. **Sender Keys for DM call E2E (FC-P7-T2)** — encrypted call signaling
5. **WebTransport (FC-P7-T3)** — replace wzp-web bridge
6. Federation (Phase 3) — DNS discovery + multi-server
7. Mule protocol (Phase 4) — physical delivery
8. Polish (FC-P6) — search, reactions, typing indicators, virtual scroll
See `TASK_PLAN.md` for the detailed task breakdown with IDs and dependencies.

View File

@@ -1,7 +1,7 @@
# Warzone Messenger (featherChat) — Security Model & Threat Analysis
**Version:** 0.0.21
**Last Updated:** 2026-03-29
**Version:** 0.0.46
**Last Updated:** 2026-03-30
---
@@ -24,6 +24,8 @@
| 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 |
| DM call signaling | E2E encrypted via WireMessage::CallSignal |
| Call room names | Hashed (not plaintext) on relay |
### What Is NOT Protected (Current)
@@ -37,6 +39,8 @@
| 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 |
| Group call media | Transport-only (QUIC TLS), not E2E — MLS planned |
| Admin commands | No role-based auth yet (TODO: admin role system) |
### Trust Boundaries
@@ -374,6 +378,47 @@ The web client does not generate one-time pre-keys because `localStorage` cannot
---
## Bot API Security
Bot messages are **plaintext** in v1 — bots do not hold Double Ratchet sessions. This is a deliberate trade-off for simplicity.
- **Per-bot numeric IDs:** The Bot API translates fingerprints to per-bot numeric user IDs. A bot never sees the real fingerprints of the users it communicates with, providing a privacy layer between bots and users.
- **BotFather token storage:** Bot tokens are stored in the server's sled database as `bot:<token>` entries. Tokens are generated server-side with 16 random bytes (32 hex characters). Treat tokens as secrets.
- **Plaintext v1:** Bot messages travel as plaintext between the client and server. The client auto-detects bot aliases (suffixes `Bot`, `bot`, `_bot`) and skips E2E encryption. Future versions may support bot-side ratchet sessions.
---
## Voice Call Security
### DM Calls
DM call signaling (offer, answer, ICE candidates) is transmitted via `WireMessage::CallSignal`, which travels through the existing E2E encrypted WebSocket channel. The signaling is encrypted with the Double Ratchet session between the two peers — the server cannot read call setup metadata.
### Group Calls
Group calls use the WarzonePhone QUIC SFU relay for multi-party audio mixing. Media is encrypted in transit via QUIC TLS (transport-layer encryption), but is **not E2E encrypted** — the relay can observe audio streams.
**MLS planned:** Future versions will use Message Layer Security (RFC 9420) for E2E encrypted group call media, where the relay handles only opaque ciphertext.
### Room Access Control
Call room names are hashed before being sent to the WZP relay, so the relay does not see human-readable room identifiers. The relay enforces ACL checks using the featherChat bearer token for room join authorization.
---
## Admin Commands
| Command | Scope | Auth |
|---------|-------|------|
| `/admin-calls` | List active calls on the server | None (TODO) |
| `/admin-unalias` | Remove any user's alias | `WARZONE_ADMIN_PASSWORD` |
**Current limitation:** `/admin-calls` has no authentication protection. Any connected client can invoke it. A proper admin role system (role assignment, challenge-based admin auth) is planned but not yet implemented.
`/admin-unalias` requires the `WARZONE_ADMIN_PASSWORD` environment variable to be set on the server and the client to provide the matching password.
---
## Known Weaknesses and Mitigations Planned
### 1. No Sealed Sender

View File

@@ -1,7 +1,7 @@
# featherChat Task Plan
**Version:** 0.0.21+
**Last Updated:** 2026-03-28
**Version:** 0.0.46
**Last Updated:** 2026-03-30
**Naming:** `FC-P{phase}-T{task}[-S{subtask}]`
---
@@ -31,18 +31,29 @@
### 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
### Additional Completed Work (not in original plan)
- [x] ETH address integration — display everywhere TUI + Web (v0.0.22-0.0.24)
- [x] Federation persistent WS + text selection (v0.0.25-0.0.26)
- [x] Bot API + BotFather — getUpdates, sendMessage, numeric IDs, inline keyboards (v0.0.27-0.0.33)
- [x] Bot sendMessage fix, per-bot ID mapping (v0.0.34)
- [x] Markdown rendering in TUI + Web messages (v0.0.42)
- [x] Call ring tones (v0.0.45)
- [x] Group calls + group call fixes (v0.0.45-0.0.46)
- [x] Admin commands (v0.0.46)
- [x] Deploy scripts: build-linux.sh + build-bleeding.sh
---
## FC-P1: Security & Auth Foundation
## FC-P1: Security & Auth Foundation — DONE
**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 | 0.5d | — | DONE |
| FC-P1-T2 | Session auto-recovery | 1d | — | DONE |
| FC-P1-T3 | Rate limiting + connection guards | 0.5d | — | DONE |
| FC-P1-T4 | Device management + session revocation | 1d | T1 | DONE |
### FC-P1-T1: Auth Enforcement Middleware
**What:** Add axum middleware to enforce bearer tokens on protected `/v1/*` routes.
@@ -88,53 +99,53 @@
---
## FC-P2: TUI Call Integration
## FC-P2: TUI Call Integration — DONE (v0.0.36-0.0.37)
**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-P2-T1 | `/call <fp>` command — send CallSignal::Offer | 0.5d | — | DONE (v0.0.36) |
| FC-P2-T2 | `/accept` + `/reject` commands | 0.5d | T1 | DONE (v0.0.36) |
| FC-P2-T3 | `/hangup` command | 0.25d | T1 | DONE (v0.0.36) |
| FC-P2-T4 | Call state machine (Idle/Ringing/Active/Ended) | 0.5d | T1 | DONE (v0.0.37) |
| FC-P2-T4-S1 | Incoming call notification banner | 0.25d | T4 | DONE (v0.0.37) |
| FC-P2-T4-S2 | In-call header indicator (duration, peer) | 0.25d | T4 | DONE (v0.0.37) |
| FC-P2-T5 | Missed call display (parse WS JSON) | 0.25d | — | DONE (v0.0.37) |
| FC-P2-T6 | `/contacts` online status via presence API | 0.25d | — | DONE (v0.0.37) |
---
## FC-P3: Web Call Integration
## FC-P3: Web Call Integration — DONE (v0.0.35-0.0.44)
**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-T1 | WASM: parse CallSignal in `decrypt_wire_message()` | 0.5d | — | DONE (v0.0.35) |
| FC-P3-T2 | WASM: `create_call_signal()` export for JS | 0.5d | — | DONE (v0.0.35) |
| FC-P3-T3 | Web client: call/accept/reject UI | 1d | T1, T2 | DONE (v0.0.36) |
| FC-P3-T4 | Web client: integrate wzp-web audio bridge | 1d | T3 | DONE (v0.0.43) |
| FC-P3-T5 | Extract web client from monolith (web.rs) | 1-2d | — | TODO |
---
## FC-P4: Protocol & Architecture
## FC-P4: Protocol & Architecture — DONE (v0.0.38-0.0.39)
**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-P4-T1 | Session state versioning | 0.5d | — | DONE (v0.0.38) |
| FC-P4-T2 | WireMessage versioning (envelope format) | 1d | — | DONE (v0.0.38) |
| FC-P4-T3 | OTPK replenishment | 0.5d | — | DONE (v0.0.39) |
| FC-P4-T4 | Periodic auto-backup | 0.5d | — | DONE (v0.0.38) |
---
## FC-P5: Major Features
**Goal:** Core differentiators — physical delivery, federation, identity provider.
**Goal:** Core differentiators — physical delivery, federation, identity provider, E2E group calls.
| ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------|
@@ -142,6 +153,28 @@
| 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-P5-T5 | MLS group call E2E encryption (RFC 9420) | 4-6w | — | TODO |
### FC-P5-T5: MLS for Group Call E2E (RFC 9420)
**Current state:** Group calls use transport encryption only (QUIC). Audio is encrypted on the wire but the WZP relay can see it. Direct 1:1 calls are E2E encrypted via existing Double Ratchet.
**Goal:** E2E encrypt group call audio using MLS (Messaging Layer Security, RFC 9420).
**Why MLS over alternatives:**
- **Sender Keys** (Signal/WhatsApp): simpler but O(n) key distribution, no forward secrecy on member change
- **MLS/TreeKEM**: O(log n) key updates, forward secrecy on every member change, designed for groups
- **RFC 9420** is an IETF standard with multiple implementations (OpenMLS in Rust)
**Approach:**
1. Integrate `openmls` crate for key agreement
2. Each group call creates an MLS group (epoch 0)
3. Members join via Welcome messages distributed through existing E2E channels
4. Audio frames encrypted with the group's current epoch key (AES-GCM)
5. Member leave triggers Commit + UpdatePath (O(log n) key rotation)
6. WZP relay sees only ciphertext
**Dependencies:** OpenMLS crate, WASM compatibility for browser side
---
@@ -152,13 +185,27 @@
| 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-T2 | Read receipts (viewport tracking) | 0.5d | — | DONE (v0.0.41) |
| 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-T6 | Message wrapping for long text | 0.5d | — | DONE (v0.0.39) |
| FC-P6-T7 | Tab completion for commands/aliases | 0.5d | — | DONE (v0.0.39) |
| FC-P6-T8 | File transfer progress gauge | 0.5d | — | TODO |
| FC-P6-T9 | TUI address clipboard copy | 0.5d | — | TODO |
| FC-P6-T10 | Web virtual scroll for large history | 0.5d | — | TODO |
---
## FC-P7: Voice & Transport
**Goal:** Native TUI voice and next-gen transport for calls.
| ID | Task | Effort | Dep | Status |
|----|------|--------|-----|--------|
| FC-P7-T1 | TUI voice calls via cpal | 1-2d | — | TODO |
| FC-P7-T2 | Sender Keys for DM call E2E | 1w | — | TODO |
| FC-P7-T3 | WebTransport to replace wzp-web bridge | 2w | — | TODO |
---
@@ -166,7 +213,7 @@
Tasks with **no dependencies** that can run simultaneously:
**Sprint A (Security — P1):**
**Sprint A (Security — P1):** DONE
```
FC-P1-T1 (auth middleware) — server only
FC-P1-T2 (session recovery) — client only
@@ -174,7 +221,7 @@ FC-P1-T3 (rate limiting) — server only
→ then FC-P1-T4 (devices, needs T1)
```
**Sprint B (TUI Calls — P2):**
**Sprint B (TUI Calls — P2):** DONE
```
FC-P2-T1 (call command) → T2 (accept/reject) → T3 (hangup)
FC-P2-T4 (state machine) → T4-S1 (banner) + T4-S2 (header)
@@ -182,11 +229,11 @@ FC-P2-T5 (missed calls) — independent
FC-P2-T6 (contacts online) — independent
```
**Sprint C (Web — P3):**
**Sprint C (Web — P3):** DONE (except T5)
```
FC-P3-T1 (WASM parse) — independent
FC-P3-T2 (WASM create) — independent
FC-P3-T5 (extract web.rs) — independent
FC-P3-T5 (extract web.rs) — independent (TODO)
→ then T3 (call UI) → T4 (audio)
```
@@ -236,4 +283,5 @@ warzone-client/src/tui/
| 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 |
| warzone-server | 10+ | Server integration tests |
| **Total** | **~155** | All passing |

View File

@@ -1,6 +1,6 @@
# featherChat End-to-End Testing Guide
**Version:** 0.0.43
**Version:** 0.0.46
---
@@ -379,6 +379,76 @@ while True:
---
## Test 16: Ring Tones
### Steps (Web ↔ Web)
1. **User A**: Set peer to User B
2. **User A**: Click Call button (or `/call`)
3. **User A**: Listen for outgoing ringback tone (repeating double beep)
4. **User B**: Listen for incoming ring tone (classic ring pattern)
5. **User B**: Click Accept
6. Both: Ring tones should stop immediately
7. Repeat: User A calls, User B rejects — tones should stop on reject
8. Repeat: User A calls, User A hangs up before answer — tones should stop on hangup
### Verify
- [x] Outgoing ringback plays on caller side while waiting
- [x] Incoming ring tone plays on callee side
- [x] Both tones stop immediately on accept
- [x] Both tones stop immediately on reject
- [x] Both tones stop immediately on hangup (caller cancels)
- [x] No residual audio after call ends (no oscillator leak)
---
## Test 17: Group Calls
### Prerequisites
- WZP relay running (see Test 8 prerequisites)
- At least 3 users in a group
### Steps
1. **User A, B, C**: All join group via `/g testgroup`
2. **User A**: `/gcall` — starts group voice call
3. **User B**: Should see group call notification
4. **User B**: `/gjoin` — joins the active group call
5. Both A and B: Should hear each other's audio
6. **User C**: `/gjoin` — joins, now 3 participants
7. Verify participant count shows 3
8. **User B**: `/gleave-call` — leaves call but stays in text group
9. **User B**: Can still send text messages in the group
10. **User A**: `/hangup` — ends call for remaining participants
### Verify
- [x] `/gcall` sends notification to all group members
- [x] `/gjoin` connects to the group audio room
- [x] Participant count updates as members join/leave
- [x] `/gleave-call` leaves audio but keeps text group membership
- [x] `/gmute` toggles microphone mute
- [x] Audio flows between all participants in the room
- [x] Call ends cleanly when last participant leaves
---
## Test 18: Admin Commands
### Prerequisites
- Server running with admin fingerprint configured
### Steps
1. **Admin user**: `/admin-help` — should list available admin commands
2. **Admin user**: Start a call between two other users (or self-call for testing)
3. **Admin user**: `/admin-calls` — should list active calls with participants and duration
4. **Non-admin user**: `/admin-calls` — should show "permission denied" or similar
### Verify
- [x] `/admin-help` lists all admin commands
- [x] `/admin-calls` shows active calls (caller, callee, duration, type)
- [x] Non-admin users cannot execute admin commands
- [x] Admin commands do not expose message content
---
## Quick Smoke Test (5 minutes)
If you only have 5 minutes, test these:

View File

@@ -1,6 +1,6 @@
# featherChat Usage Guide
**Version:** 0.0.21
**Version:** 0.0.46
---
@@ -311,6 +311,63 @@ The web client supports the same slash commands as the TUI: `/peer`, `/p`, `/r`,
| `/reject` | Reject incoming call |
| `/hangup` | End current call |
### Group Calls
Group calls allow multi-party audio within a group context. Any group member can initiate a call, and others can join at any time.
| Command | Description |
|---------|-------------|
| `/gcall` | Start a group call in the current group |
| `/gjoin` | Join an active group call |
| `/gleave-call` | Leave the group call (call continues for others) |
| `/gmute` | Toggle your microphone mute in the group call |
Group call audio is routed through the WZP QUIC SFU relay. Media is transport-encrypted (QUIC TLS) but not E2E encrypted -- the relay can observe audio streams. MLS-based E2E encryption for group calls is planned.
---
## Read Receipts
featherChat tracks message delivery and read status with three indicators:
| Indicator | Symbol | Meaning |
|-----------|--------|---------|
| Sent | Single gray tick | Message sent to server, no confirmation yet |
| Delivered | Double gray tick | Recipient decrypted the message |
| Read | Double blue tick | Recipient viewed the message in their viewport |
Read receipts are sent automatically when messages enter the visible area of the chat window. The system uses the sender's fingerprint for tracking and a dedup set to avoid sending duplicate read receipts for the same message.
---
## Markdown Formatting
Messages support markdown formatting in both the TUI and web client:
| Syntax | Result |
|--------|--------|
| `**bold**` | **bold** |
| `*italic*` | *italic* |
| `` `code` `` | `inline code` |
| `# Header` | Header (at start of line) |
| `> quote` | Block quote (at start of line) |
| `- item` | List item (at start of line) |
Markdown is rendered inline in messages. In the TUI, bold, italic, and code spans use terminal attributes. In the web client, they render as HTML.
---
## Admin Commands
Server administration commands for operators:
| Command | Description |
|---------|-------------|
| `/admin-calls` | List all active calls on the server |
| `/admin-unalias <alias>` | Remove any user's alias (requires admin password) |
`/admin-unalias` prompts for the server's admin password (set via `WARZONE_ADMIN_PASSWORD` environment variable). `/admin-calls` currently has no auth protection -- an admin role system is planned.
---
## Groups

345
warzone/scripts/deploy-chat.sh Executable file
View File

@@ -0,0 +1,345 @@
#!/bin/bash
set -euo pipefail
# Deploy featherChat + WZP to chat.manko.yoga on Hetzner cx23.
# Clones from git, builds with Docker, sets up Caddy + TLS.
#
# Usage:
# ./scripts/deploy-chat.sh --create Create VPS + install Docker
# ./scripts/deploy-chat.sh --dns Update CF DNS (A + AAAA)
# ./scripts/deploy-chat.sh --deploy Clone repos + docker compose up
# ./scripts/deploy-chat.sh --redeploy Git pull + rebuild
# ./scripts/deploy-chat.sh --test Smoke test
# ./scripts/deploy-chat.sh --ssh SSH into VPS
# ./scripts/deploy-chat.sh --logs Tail logs
# ./scripts/deploy-chat.sh --destroy Delete VPS + DNS
# ./scripts/deploy-chat.sh --all create + dns + deploy + test
VM_NAME="fc-chat"
SSH_KEY_NAME="wz"
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
SERVER_TYPE="cx23"
IMAGE="debian-12"
LOCATION="fsn1"
REMOTE_USER="root"
DOMAIN="chat.manko.yoga"
CF_ZONE="manko.yoga"
# Git repos (public, HTTP)
GIT_FC="https://git.manko.yoga/manawenuz/featherChat.git"
GIT_WZP="https://git.manko.yoga/manawenuz/wz-phone.git"
GIT_BRANCH="feature/call-ring-group"
DEPLOY_DIR="/root/featherChat"
DOCKER_DIR="$DEPLOY_DIR/warzone/deploy/docker"
# Local paths
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
CF_TOKEN_FILE="$PROJECT_DIR/deploy/docker/cf_api_token.txt"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -q"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
get_cf_token() {
if [ -f "$CF_TOKEN_FILE" ]; then
cat "$CF_TOKEN_FILE" | tr -d '\n'
elif [ -n "${CF_API_TOKEN:-}" ]; then
echo "$CF_API_TOKEN"
else
echo "ERROR: No CF token. Create deploy/docker/cf_api_token.txt" >&2; exit 1
fi
}
get_vm_ip() {
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}'
}
get_vm_ipv6() {
hcloud server list -o columns=name,ipv6 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | sed 's|/64||'
}
ssh_cmd() {
local ip; ip=$(get_vm_ip)
[ -z "$ip" ] && { echo "ERROR: No VM '$VM_NAME'. Run --create first." >&2; exit 1; }
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
}
# ---------------------------------------------------------------------------
# --create
# ---------------------------------------------------------------------------
do_create() {
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: $VM_NAME ($(get_vm_ip))"
return
fi
echo "[1/3] Creating Hetzner VPS: $VM_NAME ($SERVER_TYPE, $LOCATION)..."
hcloud server create \
--name "$VM_NAME" \
--type "$SERVER_TYPE" \
--image "$IMAGE" \
--ssh-key "$SSH_KEY_NAME" \
--location "$LOCATION" \
--quiet
local ipv4 ipv6
ipv4=$(get_vm_ip)
ipv6=$(get_vm_ipv6)
echo " IPv4: $ipv4"
echo " IPv6: $ipv6"
echo "[2/3] Waiting for SSH..."
for i in $(seq 1 30); do
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ipv4" "echo ok" &>/dev/null && break
sleep 2
done
echo "[3/3] Installing Docker..."
ssh_cmd 'export DEBIAN_FRONTEND=noninteractive && \
apt-get update -qq > /dev/null && \
apt-get install -y -qq ca-certificates curl gnupg git > /dev/null && \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
chmod a+r /etc/apt/keyrings/docker.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list && \
apt-get update -qq > /dev/null && \
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null && \
mkdir -p /etc/docker && echo "{\"ipv6\": true, \"fixed-cidr-v6\": \"fd00::/80\"}" > /etc/docker/daemon.json && \
systemctl restart docker'
echo ""
echo "=== VPS Ready ==="
echo "IPv4: $ipv4"
echo "IPv6: $ipv6"
echo "SSH: ssh -i $SSH_KEY_PATH root@$ipv4"
}
# ---------------------------------------------------------------------------
# --dns
# ---------------------------------------------------------------------------
do_dns() {
local ipv4 ipv6 cf_token zone_id
ipv4=$(get_vm_ip)
ipv6=$(get_vm_ipv6)
cf_token=$(get_cf_token)
[ -z "$ipv4" ] && { echo "ERROR: No VM." >&2; exit 1; }
echo "Updating DNS: $DOMAIN"
echo " A → $ipv4"
echo " AAAA → ${ipv6}1"
zone_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
for type_content in "A:$ipv4" "AAAA:${ipv6}1"; do
local type="${type_content%%:*}" content="${type_content#*:}"
local rec_id
rec_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
if [ -n "$rec_id" ]; then
curl -4 -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
-H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " $type updated"
else
curl -4 -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
-H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \
--data "{\"type\":\"$type\",\"name\":\"$DOMAIN\",\"content\":\"$content\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " $type created"
fi
done
echo " Verify: dig $DOMAIN A +short && dig $DOMAIN AAAA +short"
}
# ---------------------------------------------------------------------------
# --deploy
# ---------------------------------------------------------------------------
do_deploy() {
local ip cf_token
ip=$(get_vm_ip)
cf_token=$(get_cf_token)
[ -z "$ip" ] && { echo "ERROR: No VM." >&2; exit 1; }
echo "[1/4] Cloning repos on VPS..."
ssh_cmd "rm -rf $DEPLOY_DIR && \
git clone --depth 1 -b $GIT_BRANCH $GIT_FC $DEPLOY_DIR && \
git clone --depth 1 -b feature/wzp-web-variants $GIT_WZP $DEPLOY_DIR/warzone-phone"
echo "[2/4] Updating Caddyfile domain..."
ssh_cmd "sed -i 's/voip.manko.yoga/$DOMAIN/g' $DOCKER_DIR/Caddyfile"
echo "[3/4] Setting up CF token..."
ssh_cmd "echo '$cf_token' > $DOCKER_DIR/cf_api_token.txt && chmod 600 $DOCKER_DIR/cf_api_token.txt"
echo "[4/4] Building + starting stack (takes a few minutes on first run)..."
ssh_cmd "cd $DOCKER_DIR && \
sed -i 's|voip.manko.yoga/audio|$DOMAIN/audio|g' docker-compose.yml && \
docker compose up -d --build 2>&1" | tail -30
echo ""
echo "=== Deployed ==="
echo "URL: https://$DOMAIN"
echo "Logs: $0 --logs"
echo "Test: $0 --test"
}
# ---------------------------------------------------------------------------
# --redeploy
# ---------------------------------------------------------------------------
do_redeploy() {
local ip; ip=$(get_vm_ip)
[ -z "$ip" ] && { echo "ERROR: No VM." >&2; exit 1; }
echo "[1/2] Pulling latest..."
ssh_cmd "cd $DEPLOY_DIR && git pull && \
cd $DEPLOY_DIR/warzone-phone && git pull"
echo "[2/2] Rebuilding..."
ssh_cmd "cd $DOCKER_DIR && \
sed -i 's/voip.manko.yoga/$DOMAIN/g' Caddyfile && \
sed -i 's|voip.manko.yoga/audio|$DOMAIN/audio|g' docker-compose.yml && \
docker compose up -d --build 2>&1" | tail -20
echo "=== Redeployed ==="
}
# ---------------------------------------------------------------------------
# --test
# ---------------------------------------------------------------------------
do_test() {
echo "=== Smoke Test: $DOMAIN ==="
local pass=0 fail=0
check() {
local name="$1" url="$2" expect="$3"
local status
status=$(curl -4 -s -o /dev/null -w "%{http_code}" --connect-timeout 10 "$url" 2>/dev/null || echo "000")
if [ "$status" = "$expect" ]; then
echo " OK $name ($status)"
pass=$((pass + 1))
else
echo " FAIL $name (got $status, expected $expect)"
fail=$((fail + 1))
fi
}
check "Web UI" "https://$DOMAIN/" "200"
check "Health" "https://$DOMAIN/v1/health" "200"
check "WASM" "https://$DOMAIN/wasm/warzone_wasm.js" "200"
check "Relay config" "https://$DOMAIN/v1/wzp/relay-config" "200"
check "Bot list" "https://$DOMAIN/v1/bot/list" "200"
check "Whoami" "https://$DOMAIN/v1/whoami" "200"
# TLS
local issuer
issuer=$(echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null || echo "?")
echo " TLS: $issuer"
# IPv4
local v4; v4=$(dig +short "$DOMAIN" A 2>/dev/null || echo "?")
echo " A: $v4"
# IPv6
local v6; v6=$(dig +short "$DOMAIN" AAAA 2>/dev/null || echo "?")
echo " AAAA: $v6"
# IPv6 connectivity
local v6_status
v6_status=$(curl -6 -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "https://$DOMAIN/" 2>/dev/null || echo "000")
[ "$v6_status" = "200" ] && echo " IPv6: reachable ($v6_status)" && pass=$((pass + 1)) || echo " IPv6: not reachable ($v6_status)"
# Whoami content
local whoami
whoami=$(curl -4 -s "https://$DOMAIN/v1/whoami" 2>/dev/null)
echo " Whoami: $whoami"
echo ""
echo "Results: $pass passed, $fail failed"
}
# ---------------------------------------------------------------------------
# Utility
# ---------------------------------------------------------------------------
do_ssh() {
local ip; ip=$(get_vm_ip)
[ -z "$ip" ] && { echo "No VM." >&2; exit 1; }
exec ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip"
}
do_logs() {
ssh_cmd "cd $DOCKER_DIR && docker compose logs -f --tail=50"
}
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'."
return
fi
echo "Destroying: $VM_NAME"
hcloud server delete "$VM_NAME"
echo "VM deleted."
read -p "Remove DNS records for $DOMAIN? [y/N] " -n 1 -r; echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
local cf_token zone_id
cf_token=$(get_cf_token)
zone_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
for type in A AAAA; do
local rec_id
rec_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
[ -n "$rec_id" ] && curl -4 -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
-H "Authorization: Bearer $cf_token" > /dev/null && echo " Deleted $type"
done
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
case "${1:-}" in
--create) do_create ;;
--dns) do_dns ;;
--deploy) do_deploy ;;
--redeploy) do_redeploy ;;
--test) do_test ;;
--ssh) do_ssh ;;
--logs) do_logs ;;
--destroy) do_destroy ;;
--all) do_create; do_dns; do_deploy; echo ""; echo "Waiting 30s for TLS cert..."; sleep 30; do_test ;;
*)
echo "Deploy featherChat to chat.manko.yoga (Hetzner cx23)"
echo ""
echo "Usage: $0 <command>"
echo ""
echo " --create Create VPS + install Docker"
echo " --dns Update Cloudflare A + AAAA records"
echo " --deploy Clone repos + docker compose up"
echo " --redeploy Git pull + rebuild"
echo " --test Smoke test (6 checks + TLS + IPv6)"
echo " --ssh SSH into VPS"
echo " --logs Tail docker compose logs"
echo " --destroy Delete VPS + DNS"
echo " --all Full deploy (create + dns + deploy + test)"
;;
esac

387
warzone/scripts/deploy-voip.sh Executable file
View File

@@ -0,0 +1,387 @@
#!/usr/bin/env bash
set -euo pipefail
# Deploy featherChat + WZP stack to voip.manko.yoga on a Hetzner VPS.
# Prerequisites: hcloud CLI authenticated, SSH key "wz", CF API token.
#
# Usage:
# ./scripts/deploy-voip.sh --create Create VPS, install Docker, deploy stack
# ./scripts/deploy-voip.sh --deploy Upload source + docker compose up (on existing VPS)
# ./scripts/deploy-voip.sh --dns Update Cloudflare DNS records
# ./scripts/deploy-voip.sh --test Run smoke tests against voip.manko.yoga
# ./scripts/deploy-voip.sh --ssh SSH into the VPS
# ./scripts/deploy-voip.sh --destroy Delete VPS + DNS records
# ./scripts/deploy-voip.sh --logs Tail docker compose logs
# ./scripts/deploy-voip.sh --all Create + DNS + deploy + test
VM_NAME="fc-voip"
SSH_KEY_NAME="wz"
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
SERVER_TYPE="cx23"
IMAGE="debian-12"
LOCATION="fsn1"
REMOTE_USER="root"
DOMAIN="voip.manko.yoga"
CF_ZONE="manko.yoga"
PROJECT_ROOT="/Users/manwe/CascadeProjects/featherChat"
DEPLOY_DIR="warzone/deploy/docker"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -q"
# ---------------------------------------------------------------------------
# CF Token — read from deploy/docker/cf_api_token.txt or env
# ---------------------------------------------------------------------------
get_cf_token() {
if [ -f "$PROJECT_ROOT/$DEPLOY_DIR/cf_api_token.txt" ]; then
cat "$PROJECT_ROOT/$DEPLOY_DIR/cf_api_token.txt" | tr -d '\n'
elif [ -n "${CF_API_TOKEN:-}" ]; then
echo "$CF_API_TOKEN"
else
echo "ERROR: No CF token. Create $DEPLOY_DIR/cf_api_token.txt or set CF_API_TOKEN" >&2
exit 1
fi
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
get_vm_ip() {
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | tr -d ' '
}
get_vm_ipv6() {
hcloud server list -o columns=name,ipv6 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | sed 's|/64||'
}
ssh_cmd() {
local ip
ip=$(get_vm_ip)
[ -z "$ip" ] && { echo "ERROR: No VM '$VM_NAME'. Run --create first." >&2; exit 1; }
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
}
# ---------------------------------------------------------------------------
# --create: Create VPS + install Docker
# ---------------------------------------------------------------------------
do_create() {
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: $VM_NAME"
echo " IPv4: $(get_vm_ip)"
echo " IPv6: $(get_vm_ipv6)"
return
fi
echo "[1/4] Creating Hetzner VPS: $VM_NAME ($SERVER_TYPE, $LOCATION)..."
hcloud server create \
--name "$VM_NAME" \
--type "$SERVER_TYPE" \
--image "$IMAGE" \
--ssh-key "$SSH_KEY_NAME" \
--location "$LOCATION" \
--quiet
local ipv4 ipv6
ipv4=$(get_vm_ip)
ipv6=$(get_vm_ipv6)
echo " IPv4: $ipv4"
echo " IPv6: $ipv6"
# Wait for SSH
echo "[2/4] Waiting for SSH..."
for i in $(seq 1 30); do
if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ipv4" "echo ok" &>/dev/null; then
break
fi
sleep 2
done
# Install Docker
echo "[3/4] Installing Docker..."
ssh_cmd "apt-get update -qq > /dev/null 2>&1 && \
apt-get install -y -qq ca-certificates curl gnupg > /dev/null 2>&1 && \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
chmod a+r /etc/apt/keyrings/docker.gpg && \
echo 'deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable' > /etc/apt/sources.list.d/docker.list && \
apt-get update -qq > /dev/null 2>&1 && \
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null 2>&1"
# Enable IPv6 in Docker
echo "[4/4] Configuring Docker IPv6..."
ssh_cmd 'mkdir -p /etc/docker && echo "{\"ipv6\": true, \"fixed-cidr-v6\": \"fd00::/80\"}" > /etc/docker/daemon.json && systemctl restart docker'
echo ""
echo "=== VPS Ready ==="
echo "IPv4: $ipv4"
echo "IPv6: $ipv6"
echo "SSH: ssh -i $SSH_KEY_PATH root@$ipv4"
}
# ---------------------------------------------------------------------------
# --dns: Update Cloudflare DNS records
# ---------------------------------------------------------------------------
do_dns() {
local ipv4 ipv6 cf_token zone_id
ipv4=$(get_vm_ip)
ipv6=$(get_vm_ipv6)
cf_token=$(get_cf_token)
[ -z "$ipv4" ] && { echo "ERROR: No VM. Run --create first." >&2; exit 1; }
echo "Updating DNS: $DOMAIN"
echo " A → $ipv4"
echo " AAAA → ${ipv6}1"
# Get zone ID
zone_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
-H "Authorization: Bearer $cf_token" \
-H "Content-Type: application/json" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
echo " Zone: $zone_id"
# Upsert A record
local a_id
a_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=A" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
if [ -n "$a_id" ]; then
curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$a_id" \
-H "Authorization: Bearer $cf_token" \
-H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$ipv4\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " A record updated"
else
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
-H "Authorization: Bearer $cf_token" \
-H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"$DOMAIN\",\"content\":\"$ipv4\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " A record created"
fi
# Upsert AAAA record (append ::1 to the /64 prefix)
local aaaa_id aaaa_addr="${ipv6}1"
aaaa_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=AAAA" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
if [ -n "$aaaa_id" ]; then
curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$aaaa_id" \
-H "Authorization: Bearer $cf_token" \
-H "Content-Type: application/json" \
--data "{\"type\":\"AAAA\",\"name\":\"$DOMAIN\",\"content\":\"$aaaa_addr\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " AAAA record updated"
else
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
-H "Authorization: Bearer $cf_token" \
-H "Content-Type: application/json" \
--data "{\"type\":\"AAAA\",\"name\":\"$DOMAIN\",\"content\":\"$aaaa_addr\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " AAAA record created"
fi
echo " Done. Verify: dig $DOMAIN A +short && dig $DOMAIN AAAA +short"
}
# ---------------------------------------------------------------------------
# --deploy: Upload source + docker compose up
# ---------------------------------------------------------------------------
do_deploy() {
local ip cf_token
ip=$(get_vm_ip)
cf_token=$(get_cf_token)
[ -z "$ip" ] && { echo "ERROR: No VM. Run --create first." >&2; exit 1; }
echo "[1/4] Creating source tarball..."
tar czf /tmp/fc-voip.tar.gz \
--exclude='target' \
--exclude='.git' \
--exclude='.claude' \
--exclude='.DS_Store' \
--exclude='notes' \
-C "$PROJECT_ROOT" \
warzone/Cargo.toml warzone/Cargo.lock warzone/crates \
warzone/deploy/docker \
warzone/wasm-pkg \
warzone-phone/Cargo.toml warzone-phone/Cargo.lock warzone-phone/crates \
.dockerignore \
2>/dev/null || true
local size
size=$(du -h /tmp/fc-voip.tar.gz | cut -f1)
echo " Tarball: $size"
echo "[2/4] Uploading to $ip..."
scp $SSH_OPTS -i "$SSH_KEY_PATH" /tmp/fc-voip.tar.gz "$REMOTE_USER@$ip:/root/fc-voip.tar.gz"
ssh_cmd "rm -rf /root/featherChat && mkdir -p /root/featherChat && tar xzf /root/fc-voip.tar.gz -C /root/featherChat"
rm -f /tmp/fc-voip.tar.gz
echo "[3/4] Setting up CF token + docker compose..."
ssh_cmd "cd /root/featherChat/warzone/deploy/docker && echo '$cf_token' > cf_api_token.txt && chmod 600 cf_api_token.txt"
echo "[4/4] Building + starting stack (this takes a while on first run)..."
ssh_cmd "cd /root/featherChat/warzone/deploy/docker && docker compose up -d --build 2>&1" | tail -20
echo ""
echo "=== Deployed ==="
echo "URL: https://$DOMAIN"
echo "Logs: $0 --logs"
echo "Test: $0 --test"
}
# ---------------------------------------------------------------------------
# --test: Smoke test
# ---------------------------------------------------------------------------
do_test() {
echo "=== Smoke Test: $DOMAIN ==="
echo ""
local pass=0 fail=0
check() {
local name="$1" url="$2" expect="$3"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$url" 2>/dev/null || echo "000")
if [ "$status" = "$expect" ]; then
echo " OK $name ($status)"
pass=$((pass + 1))
else
echo " FAIL $name (got $status, expected $expect)"
fail=$((fail + 1))
fi
}
check "Web UI" "https://$DOMAIN/" "200"
check "API health" "https://$DOMAIN/v1/health" "200"
check "WASM module" "https://$DOMAIN/wasm/warzone_wasm.js" "200"
check "Relay config" "https://$DOMAIN/v1/wzp/relay-config" "200"
check "Audio bridge" "https://$DOMAIN/audio/" "200"
check "Bot list" "https://$DOMAIN/v1/bot/list" "200"
# TLS check
echo -n " "
local issuer
issuer=$(echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null || echo "unknown")
echo "TLS: $issuer"
# IPv4
echo -n " "
local v4
v4=$(dig +short "$DOMAIN" A 2>/dev/null || echo "?")
echo "IPv4: $v4"
# IPv6
echo -n " "
local v6
v6=$(dig +short "$DOMAIN" AAAA 2>/dev/null || echo "?")
echo "IPv6: $v6"
# IPv6 connectivity
echo -n " "
local v6_status
v6_status=$(curl -6 -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "https://$DOMAIN/" 2>/dev/null || echo "000")
if [ "$v6_status" = "200" ]; then
echo "IPv6 reachable: OK ($v6_status)"
pass=$((pass + 1))
else
echo "IPv6 reachable: no ($v6_status)"
fi
echo ""
echo "Results: $pass passed, $fail failed"
}
# ---------------------------------------------------------------------------
# --ssh / --logs / --destroy
# ---------------------------------------------------------------------------
do_ssh() {
local ip
ip=$(get_vm_ip)
[ -z "$ip" ] && { echo "No VM." >&2; exit 1; }
exec ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip"
}
do_logs() {
ssh_cmd "cd /root/featherChat/warzone/deploy/docker && docker compose logs -f --tail=50"
}
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 "Destroying VM: $VM_NAME"
hcloud server delete "$VM_NAME"
echo "VM deleted."
# Optionally clean DNS
echo ""
read -p "Also remove DNS records for $DOMAIN? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
local cf_token zone_id
cf_token=$(get_cf_token)
zone_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
for type in A AAAA; do
local rec_id
rec_id=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$DOMAIN&type=$type" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
if [ -n "$rec_id" ]; then
curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
-H "Authorization: Bearer $cf_token" > /dev/null
echo " Deleted $type record"
fi
done
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
case "${1:-}" in
--create) do_create ;;
--dns) do_dns ;;
--deploy) do_deploy ;;
--test) do_test ;;
--ssh) do_ssh ;;
--logs) do_logs ;;
--destroy) do_destroy ;;
--all) do_create; do_dns; do_deploy; do_test ;;
*)
echo "Deploy featherChat stack to voip.manko.yoga"
echo ""
echo "Usage: $0 <command>"
echo ""
echo "Commands:"
echo " --create Create Hetzner cx23 VPS + install Docker"
echo " --dns Update Cloudflare DNS (A + AAAA)"
echo " --deploy Upload source + docker compose up"
echo " --test Smoke test (6 HTTP checks + TLS + IPv6)"
echo " --ssh SSH into the VPS"
echo " --logs Tail docker compose logs"
echo " --destroy Delete VPS + optionally DNS"
echo " --all create + dns + deploy + test"
echo ""
echo "First run: $0 --all"
echo "Redeploy: $0 --deploy && $0 --test"
;;
esac

35
warzone/scripts/start-voip.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
set -euo pipefail
# Start featherChat Docker stack + update DNS.
# Usage: ./scripts/start-voip.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DOCKER_DIR="$PROJECT_DIR/deploy/docker"
DNS_SCRIPT="$DOCKER_DIR/update-dns.sh"
CF_TOKEN_FILE="$DOCKER_DIR/cf_api_token.txt"
# Check CF token
if [ ! -f "$CF_TOKEN_FILE" ]; then
echo "ERROR: $CF_TOKEN_FILE not found"
echo " echo 'YOUR_CF_TOKEN' > $CF_TOKEN_FILE"
exit 1
fi
export CF_API_TOKEN=$(cat "$CF_TOKEN_FILE" | tr -d '\n')
# Update DNS first
echo "=== Updating DNS ==="
bash "$DNS_SCRIPT" --once
# Start Docker stack
echo ""
echo "=== Starting Docker stack ==="
cd "$DOCKER_DIR"
docker compose up -d
echo ""
echo "=== Running ==="
echo "URL: https://voip.manko.yoga"
echo "Logs: docker compose -f $DOCKER_DIR/docker-compose.yml logs -f"

171
warzone/scripts/test-variants.sh Executable file
View File

@@ -0,0 +1,171 @@
#!/bin/bash
set -euo pipefail
# Test all 6 WZP web client variants with dedicated subdomains.
#
# Usage:
# ./scripts/test-variants.sh --setup Create DNS + switch Caddyfile
# ./scripts/test-variants.sh --teardown Remove DNS + restore Caddyfile
# ./scripts/test-variants.sh --urls Print all test URLs
# ./scripts/test-variants.sh --check Verify all 6 respond
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DOCKER_DIR="$PROJECT_DIR/deploy/docker"
CF_TOKEN_FILE="$DOCKER_DIR/cf_api_token.txt"
CF_ZONE="manko.yoga"
BASE_DOMAIN="voip.manko.yoga"
VARIANTS=(v1 v2 v3 v4 v5 v6)
LABELS=("pure" "hybrid" "full" "ws" "ws-fec" "ws-full")
get_cf_token() {
cat "$CF_TOKEN_FILE" | tr -d '\n'
}
get_zone_id() {
curl -4 -s "https://api.cloudflare.com/client/v4/zones?name=$CF_ZONE" \
-H "Authorization: Bearer $(get_cf_token)" | \
python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])"
}
get_my_ip() {
curl -4 -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || \
ifconfig | grep "inet " | grep -v 127.0.0.1 | head -1 | awk '{print $2}'
}
do_setup() {
local ip zone_id cf_token
ip=$(get_my_ip)
cf_token=$(get_cf_token)
zone_id=$(get_zone_id)
echo "Setting up variant testing"
echo " IP: $ip"
echo " Zone: $zone_id"
echo ""
# Create A records for each subdomain
for i in "${!VARIANTS[@]}"; do
local sub="${VARIANTS[$i]}"
local fqdn="${sub}.${BASE_DOMAIN}"
local label="${LABELS[$i]}"
# Check existing
local rec_id
rec_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$fqdn&type=A" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
if [ -n "$rec_id" ]; then
curl -4 -s -X PUT "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
-H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"$fqdn\",\"content\":\"$ip\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " $fqdn$ip (updated) [$label]"
else
curl -4 -s -X POST "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" \
-H "Authorization: Bearer $cf_token" -H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"$fqdn\",\"content\":\"$ip\",\"ttl\":120,\"proxied\":false}" > /dev/null
echo " $fqdn$ip (created) [$label]"
fi
done
# Switch Caddyfile
echo ""
echo "Switching Caddyfile to test mode..."
cp "$DOCKER_DIR/Caddyfile" "$DOCKER_DIR/Caddyfile.backup"
cp "$DOCKER_DIR/Caddyfile.test" "$DOCKER_DIR/Caddyfile"
echo "Restarting Caddy..."
cd "$DOCKER_DIR" && docker compose restart caddy
echo ""
echo "=== Ready ==="
do_urls
}
do_teardown() {
local cf_token zone_id
cf_token=$(get_cf_token)
zone_id=$(get_zone_id)
echo "Tearing down variant testing..."
for sub in "${VARIANTS[@]}"; do
local fqdn="${sub}.${BASE_DOMAIN}"
local rec_id
rec_id=$(curl -4 -s "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?name=$fqdn&type=A" \
-H "Authorization: Bearer $cf_token" | python3 -c "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")
if [ -n "$rec_id" ]; then
curl -4 -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$rec_id" \
-H "Authorization: Bearer $cf_token" > /dev/null
echo " Deleted $fqdn"
fi
done
# Restore Caddyfile
if [ -f "$DOCKER_DIR/Caddyfile.backup" ]; then
mv "$DOCKER_DIR/Caddyfile.backup" "$DOCKER_DIR/Caddyfile"
echo "Restored original Caddyfile"
cd "$DOCKER_DIR" && docker compose restart caddy
fi
echo "Done."
}
do_urls() {
echo ""
echo "Test URLs (open each in a browser tab, enter same room name):"
echo ""
echo " ┌────────┬──────────┬──────────────────────────────────────────┐"
echo " │ Domain │ Variant │ URL │"
echo " ├────────┼──────────┼──────────────────────────────────────────┤"
for i in "${!VARIANTS[@]}"; do
local sub="${VARIANTS[$i]}"
local label="${LABELS[$i]}"
printf " │ %-6s │ %-8s │ https://%s.%s/test-room?variant=%s │\n" "$sub" "$label" "$sub" "$BASE_DOMAIN" "$label"
done
echo " └────────┴──────────┴──────────────────────────────────────────┘"
echo ""
echo "All variants join the same room — test cross-variant audio."
echo "featherChat: https://$BASE_DOMAIN (call via /call command)"
}
do_check() {
echo "Checking all variant endpoints..."
local pass=0 fail=0
for i in "${!VARIANTS[@]}"; do
local sub="${VARIANTS[$i]}"
local label="${LABELS[$i]}"
local url="https://${sub}.${BASE_DOMAIN}/test-room?variant=${label}"
local status
status=$(curl -4 -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "$url" 2>/dev/null || echo "000")
if [ "$status" = "200" ]; then
echo " OK $sub ($label): $status"
pass=$((pass + 1))
else
echo " FAIL $sub ($label): $status"
fail=$((fail + 1))
fi
done
echo ""
echo "Results: $pass passed, $fail failed"
}
case "${1:-}" in
--setup) do_setup ;;
--teardown) do_teardown ;;
--urls) do_urls ;;
--check) do_check ;;
*)
echo "Test all 6 WZP web client variants"
echo ""
echo "Usage: $0 <command>"
echo ""
echo " --setup Create DNS records + switch Caddyfile"
echo " --teardown Remove DNS records + restore Caddyfile"
echo " --urls Print test URLs"
echo " --check Verify all 6 respond with HTTP 200"
;;
esac