Compare commits
31 Commits
feature/wz
...
feature/ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c248442c2 | ||
|
|
5ae87be316 | ||
|
|
f698b25fad | ||
|
|
7924871559 | ||
|
|
8a4f0ef8ee | ||
|
|
561f2d6978 | ||
|
|
da3cdd7234 | ||
|
|
cc76004655 | ||
|
|
9af5ec96b5 | ||
|
|
02471b28ba | ||
|
|
74af18463e | ||
|
|
b22200e3be | ||
|
|
850944944d | ||
|
|
47030a3b29 | ||
|
|
cac812665c | ||
|
|
f272a82faf | ||
|
|
11133cf968 | ||
|
|
8b00144b2f | ||
|
|
bf9594f1de | ||
|
|
366ab30988 | ||
|
|
fb29eb0fce | ||
|
|
33c39c6541 | ||
|
|
3d387e5821 | ||
|
|
38f992c284 | ||
|
|
59d68b2a5e | ||
|
|
f33ac1cad8 | ||
|
|
c2be68ca20 | ||
|
|
d7b75a6641 | ||
|
|
93923676a8 | ||
|
|
2612d46f5c | ||
|
|
983afc5916 |
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
**/target
|
||||
**/node_modules
|
||||
**/.git
|
||||
**/.DS_Store
|
||||
**/.claude
|
||||
**/wasm-pkg
|
||||
apache
|
||||
nginx
|
||||
nginx.txt
|
||||
chat.py
|
||||
tunnel.py
|
||||
DESIGN.md
|
||||
Submodule warzone-phone updated: 6f4e8eb9f6...1d33f3ed4e
@@ -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
10
warzone/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -9,7 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.44"
|
||||
version = "0.0.47"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
12
warzone/deploy/docker/.env.example
Normal file
12
warzone/deploy/docker/.env.example
Normal 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)
|
||||
30
warzone/deploy/docker/Caddyfile
Normal file
30
warzone/deploy/docker/Caddyfile
Normal 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
|
||||
}
|
||||
42
warzone/deploy/docker/Caddyfile.test
Normal file
42
warzone/deploy/docker/Caddyfile.test
Normal 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
|
||||
}
|
||||
12
warzone/deploy/docker/Dockerfile.caddy
Normal file
12
warzone/deploy/docker/Dockerfile.caddy
Normal 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
|
||||
30
warzone/deploy/docker/Dockerfile.server
Normal file
30
warzone/deploy/docker/Dockerfile.server
Normal 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"]
|
||||
30
warzone/deploy/docker/Dockerfile.wzp
Normal file
30
warzone/deploy/docker/Dockerfile.wzp
Normal 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
|
||||
24
warzone/deploy/docker/docker-compose.ipv6.yml
Normal file
24
warzone/deploy/docker/docker-compose.ipv6.yml
Normal 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
|
||||
97
warzone/deploy/docker/docker-compose.yml
Normal file
97
warzone/deploy/docker/docker-compose.yml
Normal 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
|
||||
58
warzone/deploy/docker/test-stack.sh
Executable file
58
warzone/deploy/docker/test-stack.sh
Executable 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 ==="
|
||||
98
warzone/deploy/docker/update-dns.sh
Executable file
98
warzone/deploy/docker/update-dns.sh
Executable 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
345
warzone/scripts/deploy-chat.sh
Executable 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
387
warzone/scripts/deploy-voip.sh
Executable 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
35
warzone/scripts/start-voip.sh
Executable 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
171
warzone/scripts/test-variants.sh
Executable 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
|
||||
Reference in New Issue
Block a user