fix: WASM double-X3DH bug, federated aliases, deploy tooling
WASM fix (critical):
- encrypt_key_exchange_with_id was calling x3dh::initiate a second time,
generating a new ephemeral key that didn't match the ratchet — receiver
always failed to decrypt. Now stores X3DH result from initiate() and
reuses it. Added 2 protocol tests confirming the fix + the bug.
- Bumped service worker cache to wz-v2 to force browsers to re-fetch.
- Disabled wasm-opt for Hetzner builds (libc compat issue).
Federation — alias support:
- resolve_alias falls back to federation peer if not found locally
- register_alias checks peer server before allowing — globally unique aliases
- Added resolve_remote_alias() and is_alias_taken_remote() to FederationHandle
Federation — key proxy fix:
- Remote bundles no longer cached locally (stale cache caused decrypt failures)
- Local vs remote determined by device: prefix in keys DB
Client fixes:
- Self-messaging blocked ("Cannot send messages to yourself")
- /peer <self> blocked
- last_dm_peer never set to self
- /r <message> sends reply inline (switches peer + sends in one command)
Deploy tooling:
- scripts/build-linux.sh with --ship (build + deploy + destroy)
- --update-all, --status, --logs commands
- WASM rebuilt on Hetzner VM before server binary
- deploy/ directory: systemd service, federation configs, setup script
- Journald log cap (50MB, 7-day retention)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ impl App {
|
||||
db: &LocalDb,
|
||||
client: &ServerClient,
|
||||
) {
|
||||
let text = self.input.trim().to_string();
|
||||
let mut text = self.input.trim().to_string();
|
||||
self.input.clear();
|
||||
self.cursor_pos = 0;
|
||||
|
||||
@@ -223,15 +223,27 @@ impl App {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if text == "/r" || text == "/reply" {
|
||||
if text == "/r" || text == "/reply" || text.starts_with("/r ") || text.starts_with("/reply ") {
|
||||
let last = self.last_dm_peer.lock().unwrap().clone();
|
||||
if let Some(ref peer) = last {
|
||||
self.peer_fp = Some(peer.clone());
|
||||
self.add_message(ChatLine { sender: "system".into(), text: format!("→ switched to {}", &peer[..peer.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
// If there's a message after /r, mutate text and fall through to send
|
||||
let reply_msg = if text.starts_with("/reply ") {
|
||||
text[7..].trim().to_string()
|
||||
} else if text.starts_with("/r ") {
|
||||
text[3..].trim().to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if reply_msg.is_empty() {
|
||||
return; // Just switch peer
|
||||
}
|
||||
text = reply_msg; // Fall through to send logic below
|
||||
} else {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if text.starts_with("/peer ") || text.starts_with("/p ") {
|
||||
let text = if text.starts_with("/p ") { format!("/peer {}", &text[3..]) } else { text.clone() };
|
||||
@@ -244,6 +256,10 @@ impl App {
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
if normfp(&fp) == normfp(&self.our_fp) {
|
||||
self.add_message(ChatLine { sender: "system".into(), text: "Cannot set yourself as peer".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
|
||||
return;
|
||||
}
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: format!("Peer set to {}", fp),
|
||||
@@ -355,6 +371,18 @@ impl App {
|
||||
}
|
||||
};
|
||||
|
||||
// Prevent self-messaging (causes ratchet corruption)
|
||||
if normfp(&peer) == normfp(&self.our_fp) {
|
||||
self.add_message(ChatLine {
|
||||
sender: "system".into(),
|
||||
text: "Cannot send messages to yourself".into(),
|
||||
is_system: true,
|
||||
is_self: false,
|
||||
message_id: None, timestamp: Local::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let peer_fp = match Fingerprint::from_hex(&peer) {
|
||||
Ok(fp) => fp,
|
||||
Err(_) => {
|
||||
|
||||
@@ -112,7 +112,9 @@ fn process_wire_message(
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||
let _ = db.save_session(&sender_fp, &state);
|
||||
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
|
||||
if normfp(&sender_fingerprint) != normfp(our_fp) {
|
||||
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
|
||||
}
|
||||
store_received(db, &sender_fingerprint, &text);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
|
||||
@@ -159,7 +161,9 @@ fn process_wire_message(
|
||||
Ok(plaintext) => {
|
||||
let text = String::from_utf8_lossy(&plaintext).to_string();
|
||||
let _ = db.save_session(&sender_fp, &state);
|
||||
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
|
||||
if normfp(&sender_fingerprint) != normfp(our_fp) {
|
||||
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
|
||||
}
|
||||
store_received(db, &sender_fingerprint, &text);
|
||||
messages.lock().unwrap().push(ChatLine {
|
||||
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
|
||||
|
||||
@@ -163,4 +163,141 @@ mod tests {
|
||||
|
||||
assert_eq!(alice_result.shared_secret, bob_secret);
|
||||
}
|
||||
|
||||
/// Simulate the EXACT web client (WASM) flow:
|
||||
/// 1. Alice: generate identity + SPK, create bundle, register
|
||||
/// 2. Bob: same
|
||||
/// 3. Alice: fetch Bob's bundle, WasmSession::initiate (X3DH), encrypt_key_exchange
|
||||
/// 4. Bob: receive wire bytes, decrypt_wire_message (X3DH respond + ratchet decrypt)
|
||||
#[test]
|
||||
fn web_client_x3dh_roundtrip() {
|
||||
use crate::identity::Seed;
|
||||
use crate::message::WireMessage;
|
||||
use crate::ratchet::RatchetState;
|
||||
|
||||
// === Alice ===
|
||||
let alice_seed = Seed::generate();
|
||||
let alice_id = alice_seed.derive_identity();
|
||||
let alice_pub = alice_id.public_identity();
|
||||
let (alice_spk_secret, alice_spk) = generate_signed_pre_key(&alice_id, 1);
|
||||
let alice_bundle = PreKeyBundle {
|
||||
identity_key: *alice_pub.signing.as_bytes(),
|
||||
identity_encryption_key: *alice_pub.encryption.as_bytes(),
|
||||
signed_pre_key: alice_spk,
|
||||
one_time_pre_key: None, // web client: no OTPKs
|
||||
};
|
||||
|
||||
// === Bob ===
|
||||
let bob_seed = Seed::generate();
|
||||
let bob_id = bob_seed.derive_identity();
|
||||
let bob_pub = bob_id.public_identity();
|
||||
let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1);
|
||||
let bob_spk_secret_bytes = bob_spk_secret.to_bytes();
|
||||
let bob_bundle = PreKeyBundle {
|
||||
identity_key: *bob_pub.signing.as_bytes(),
|
||||
identity_encryption_key: *bob_pub.encryption.as_bytes(),
|
||||
signed_pre_key: bob_spk,
|
||||
one_time_pre_key: None,
|
||||
};
|
||||
let bob_bundle_bytes = bincode::serialize(&bob_bundle).unwrap();
|
||||
|
||||
// === Alice sends to Bob (simulating WasmSession::initiate + encrypt_key_exchange_with_id) ===
|
||||
|
||||
// Step 1: WasmSession::initiate — X3DH + init ratchet
|
||||
let x3dh_result = initiate(&alice_id, &bob_bundle).unwrap();
|
||||
let their_spk = PublicKey::from(bob_bundle.signed_pre_key.public_key);
|
||||
let mut alice_ratchet = RatchetState::init_alice(x3dh_result.shared_secret, their_spk);
|
||||
|
||||
// Step 2: encrypt_key_exchange_with_id — use SAME x3dh_result (NOT re-initiate!)
|
||||
let encrypted = alice_ratchet.encrypt(b"hello bob").unwrap();
|
||||
let wire = WireMessage::KeyExchange {
|
||||
id: "test-msg-001".to_string(),
|
||||
sender_fingerprint: alice_pub.fingerprint.to_string(),
|
||||
sender_identity_encryption_key: *alice_pub.encryption.as_bytes(),
|
||||
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
|
||||
used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id,
|
||||
ratchet_message: encrypted,
|
||||
};
|
||||
let wire_bytes = bincode::serialize(&wire).unwrap();
|
||||
|
||||
// === Bob decrypts (simulating decrypt_wire_message) ===
|
||||
let wire_in: WireMessage = bincode::deserialize(&wire_bytes).unwrap();
|
||||
match wire_in {
|
||||
WireMessage::KeyExchange {
|
||||
sender_identity_encryption_key,
|
||||
ephemeral_public,
|
||||
ratchet_message,
|
||||
..
|
||||
} => {
|
||||
let bob_spk_secret_restored = StaticSecret::from(bob_spk_secret_bytes);
|
||||
let their_id = PublicKey::from(sender_identity_encryption_key);
|
||||
let their_eph = PublicKey::from(ephemeral_public);
|
||||
|
||||
let shared = respond(
|
||||
&bob_id, &bob_spk_secret_restored, None, &their_id, &their_eph,
|
||||
).unwrap();
|
||||
|
||||
let bob_spk_for_ratchet = StaticSecret::from(bob_spk_secret_bytes);
|
||||
let mut bob_ratchet = RatchetState::init_bob(shared, bob_spk_for_ratchet);
|
||||
let plaintext = bob_ratchet.decrypt(&ratchet_message).unwrap();
|
||||
|
||||
assert_eq!(plaintext, b"hello bob");
|
||||
}
|
||||
_ => panic!("expected KeyExchange"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that the OLD buggy flow (double X3DH initiate) fails,
|
||||
/// confirming the bug we found.
|
||||
#[test]
|
||||
fn double_x3dh_initiate_fails() {
|
||||
use crate::identity::Seed;
|
||||
use crate::ratchet::RatchetState;
|
||||
|
||||
let alice_seed = Seed::generate();
|
||||
let alice_id = alice_seed.derive_identity();
|
||||
let alice_pub = alice_id.public_identity();
|
||||
|
||||
let bob_seed = Seed::generate();
|
||||
let bob_id = bob_seed.derive_identity();
|
||||
let bob_pub = bob_id.public_identity();
|
||||
let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1);
|
||||
let bob_spk_secret_bytes = bob_spk_secret.to_bytes();
|
||||
let bob_bundle = PreKeyBundle {
|
||||
identity_key: *bob_pub.signing.as_bytes(),
|
||||
identity_encryption_key: *bob_pub.encryption.as_bytes(),
|
||||
signed_pre_key: bob_spk,
|
||||
one_time_pre_key: None,
|
||||
};
|
||||
|
||||
// FIRST X3DH — used for ratchet
|
||||
let result1 = initiate(&alice_id, &bob_bundle).unwrap();
|
||||
let their_spk = PublicKey::from(bob_bundle.signed_pre_key.public_key);
|
||||
let mut alice_ratchet = RatchetState::init_alice(result1.shared_secret, their_spk);
|
||||
let encrypted = alice_ratchet.encrypt(b"test").unwrap();
|
||||
|
||||
// SECOND X3DH — different ephemeral key (THE BUG)
|
||||
let result2 = initiate(&alice_id, &bob_bundle).unwrap();
|
||||
// result2.ephemeral_public != result1.ephemeral_public
|
||||
assert_ne!(
|
||||
result1.ephemeral_public.as_bytes(),
|
||||
result2.ephemeral_public.as_bytes(),
|
||||
"two X3DH initiates should produce different ephemeral keys"
|
||||
);
|
||||
|
||||
// Bob tries to decrypt using result2's ephemeral (wrong one)
|
||||
let bob_spk_restored = StaticSecret::from(bob_spk_secret_bytes);
|
||||
let shared = respond(
|
||||
&bob_id, &bob_spk_restored, None,
|
||||
&alice_pub.encryption, &result2.ephemeral_public,
|
||||
).unwrap();
|
||||
|
||||
// The shared secrets DIFFER because different ephemeral keys
|
||||
assert_ne!(result1.shared_secret, shared, "mismatched ephemeral should produce different shared secret");
|
||||
|
||||
// Decryption should FAIL
|
||||
let bob_spk_for_ratchet = StaticSecret::from(bob_spk_secret_bytes);
|
||||
let mut bob_ratchet = RatchetState::init_bob(shared, bob_spk_for_ratchet);
|
||||
assert!(bob_ratchet.decrypt(&encrypted).is_err(), "decrypt should fail with wrong shared secret");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ pub struct FederationHandle {
|
||||
pub remote_presence: Arc<Mutex<RemotePresence>>,
|
||||
/// Channel to send messages over the outgoing WS to the peer.
|
||||
pub outgoing: FederationSender,
|
||||
/// HTTP client for one-shot requests (key fetch, etc.)
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl FederationHandle {
|
||||
@@ -72,10 +74,15 @@ impl FederationHandle {
|
||||
let remote_presence = Arc::new(Mutex::new(RemotePresence::new(
|
||||
config.peer.id.clone(),
|
||||
)));
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
FederationHandle {
|
||||
config,
|
||||
remote_presence,
|
||||
outgoing: Arc::new(Mutex::new(None)),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +103,41 @@ impl FederationHandle {
|
||||
self.send_json(msg).await
|
||||
}
|
||||
|
||||
/// Fetch a pre-key bundle from the peer server (HTTP GET fallback).
|
||||
/// Used when a local key lookup fails and the fingerprint is on the remote.
|
||||
pub async fn fetch_remote_bundle(&self, fingerprint: &str) -> Option<Vec<u8>> {
|
||||
let url = format!("{}/v1/keys/{}", self.config.peer.url, fingerprint);
|
||||
let resp = self.client.get(&url).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await.ok()?;
|
||||
let bundle_b64 = data.get("bundle")?.as_str()?;
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, bundle_b64).ok()
|
||||
}
|
||||
|
||||
/// Resolve an alias on the peer server.
|
||||
/// Returns Some(fingerprint) if the peer knows this alias.
|
||||
pub async fn resolve_remote_alias(&self, alias: &str) -> Option<String> {
|
||||
let url = format!("{}/v1/alias/resolve/{}", self.config.peer.url, alias);
|
||||
let resp = self.client.get(&url).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await.ok()?;
|
||||
// Check for error (alias not found on peer)
|
||||
if data.get("error").is_some() {
|
||||
return None;
|
||||
}
|
||||
data.get("fingerprint").and_then(|v| v.as_str()).map(String::from)
|
||||
}
|
||||
|
||||
/// Check if an alias is already taken on the peer server.
|
||||
/// Returns true if the alias exists on the peer (taken).
|
||||
pub async fn is_alias_taken_remote(&self, alias: &str) -> bool {
|
||||
self.resolve_remote_alias(alias).await.is_some()
|
||||
}
|
||||
|
||||
/// Push local presence to peer via the persistent WS.
|
||||
pub async fn push_presence(&self, fingerprints: Vec<String>) -> bool {
|
||||
let msg = serde_json::json!({
|
||||
@@ -171,10 +213,42 @@ pub async fn outgoing_ws_loop(
|
||||
};
|
||||
let _ = handle.push_presence(fps).await;
|
||||
|
||||
// Spawn task to forward outgoing channel to WS
|
||||
// Spawn task to forward outgoing channel + periodic ping to WS
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(msg) = out_rx.recv().await {
|
||||
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(msg)).await.is_err() {
|
||||
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15));
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = out_rx.recv() => {
|
||||
match msg {
|
||||
Some(text) => {
|
||||
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
_ = ping_interval.tick() => {
|
||||
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Ping(vec![])).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn task to periodically re-push presence
|
||||
let presence_handle = handle.clone();
|
||||
let presence_conns = state.connections.clone();
|
||||
let presence_task = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let fps: Vec<String> = {
|
||||
let conns = presence_conns.lock().await;
|
||||
conns.keys().cloned().collect()
|
||||
};
|
||||
if !presence_handle.push_presence(fps).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -182,13 +256,19 @@ pub async fn outgoing_ws_loop(
|
||||
|
||||
// Read incoming messages from peer
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
if let tokio_tungstenite::tungstenite::Message::Text(text) = msg {
|
||||
handle_incoming_federation_msg(&text, &handle, &state).await;
|
||||
match msg {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
handle_incoming_federation_msg(&text, &handle, &state).await;
|
||||
}
|
||||
tokio_tungstenite::tungstenite::Message::Pong(_) => {} // keepalive response
|
||||
tokio_tungstenite::tungstenite::Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection lost
|
||||
send_task.abort();
|
||||
presence_task.abort();
|
||||
{
|
||||
let mut guard = handle.outgoing.lock().await;
|
||||
*guard = None;
|
||||
|
||||
@@ -152,6 +152,13 @@ async fn register_alias(
|
||||
delete_alias_record(&state.db.aliases, &existing)?;
|
||||
}
|
||||
|
||||
// Check if alias is taken on federation peer (globally unique)
|
||||
if let Some(ref federation) = state.federation {
|
||||
if federation.is_alias_taken_remote(&alias).await {
|
||||
return Ok(Json(serde_json::json!({ "error": "alias already taken on federated server" })));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old alias for this fingerprint (one alias per person)
|
||||
if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
|
||||
let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string();
|
||||
@@ -292,7 +299,20 @@ async fn resolve_alias(
|
||||
})))
|
||||
}
|
||||
}
|
||||
None => Ok(Json(serde_json::json!({ "error": "alias not found" }))),
|
||||
None => {
|
||||
// Try federation peer
|
||||
if let Some(ref federation) = state.federation {
|
||||
if let Some(fp) = federation.resolve_remote_alias(&alias).await {
|
||||
tracing::info!("Alias @{} resolved via federation: {}", alias, fp);
|
||||
return Ok(Json(serde_json::json!({
|
||||
"alias": alias,
|
||||
"fingerprint": fp,
|
||||
"federated": true,
|
||||
})));
|
||||
}
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "error": "alias not found" })))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ struct RegisterResponse {
|
||||
}
|
||||
|
||||
async fn register_keys(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Json<RegisterResponse> {
|
||||
@@ -85,9 +85,26 @@ async fn get_bundle(
|
||||
.collect();
|
||||
tracing::info!("get_bundle: DB contains {} keys: {:?}", all_keys.len(), all_keys);
|
||||
|
||||
// Check if this fingerprint registered locally (has a device: entry)
|
||||
let device_prefix = format!("device:{}:", key);
|
||||
let is_local = state.db.keys.scan_prefix(device_prefix.as_bytes()).next().is_some();
|
||||
|
||||
// For remote clients, always proxy from the federation peer (bundles may change)
|
||||
if !is_local {
|
||||
if let Some(ref federation) = state.federation {
|
||||
if let Some(bundle_bytes) = federation.fetch_remote_bundle(&key).await {
|
||||
tracing::info!("get_bundle: PROXIED from federation peer for {}", key);
|
||||
return Ok(Json(serde_json::json!({
|
||||
"fingerprint": fingerprint,
|
||||
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bundle_bytes),
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match state.db.keys.get(key.as_bytes()) {
|
||||
Ok(Some(data)) => {
|
||||
tracing::info!("get_bundle: FOUND {} bytes for {}", data.len(), key);
|
||||
tracing::info!("get_bundle: FOUND {} bytes for {} (local={})", data.len(), key, is_local);
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": fingerprint,
|
||||
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data),
|
||||
@@ -130,7 +147,7 @@ struct OtpkEntry {
|
||||
|
||||
/// Upload additional one-time pre-keys.
|
||||
async fn replenish_otpks(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ReplenishRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
|
||||
@@ -71,7 +71,7 @@ fn normalize_fp(fp: &str) -> String {
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SendRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
|
||||
@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
|
||||
|
||||
async fn service_worker() -> impl IntoResponse {
|
||||
([(header::CONTENT_TYPE, "application/javascript")], r##"
|
||||
const CACHE = 'wz-v1';
|
||||
const CACHE = 'wz-v2';
|
||||
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
|
||||
@@ -3,6 +3,9 @@ name = "warzone-wasm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
|
||||
@@ -132,6 +132,9 @@ impl WasmIdentity {
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmSession {
|
||||
ratchet: RatchetState,
|
||||
/// Stored X3DH result from initiate() — needed for encrypt_key_exchange
|
||||
x3dh_ephemeral_public: Option<[u8; 32]>,
|
||||
x3dh_used_otpk_id: Option<u32>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
@@ -147,6 +150,8 @@ impl WasmSession {
|
||||
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
|
||||
Ok(WasmSession {
|
||||
ratchet: RatchetState::init_alice(result.shared_secret, their_spk),
|
||||
x3dh_ephemeral_public: Some(*result.ephemeral_public.as_bytes()),
|
||||
x3dh_used_otpk_id: result.used_one_time_pre_key_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,14 +167,14 @@ impl WasmSession {
|
||||
pub fn encrypt_key_exchange_with_id(
|
||||
&mut self,
|
||||
identity: &WasmIdentity,
|
||||
their_bundle_bytes: &[u8],
|
||||
_their_bundle_bytes: &[u8],
|
||||
plaintext: &str,
|
||||
msg_id: &str,
|
||||
) -> Result<Vec<u8>, JsValue> {
|
||||
let bundle: PreKeyBundle = bincode::deserialize(their_bundle_bytes)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
let result = x3dh::initiate(&identity.identity, &bundle)
|
||||
.map_err(|e| JsValue::from_str(&format!("X3DH: {}", e)))?;
|
||||
// Use the stored X3DH result from initiate() — DO NOT re-initiate
|
||||
// (re-initiating generates a new ephemeral key that doesn't match the ratchet)
|
||||
let ephemeral_public = self.x3dh_ephemeral_public
|
||||
.ok_or_else(|| JsValue::from_str("no X3DH result — call initiate() first"))?;
|
||||
|
||||
let encrypted = self.ratchet.encrypt(plaintext.as_bytes())
|
||||
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
|
||||
@@ -178,8 +183,8 @@ impl WasmSession {
|
||||
id: msg_id.to_string(),
|
||||
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
|
||||
sender_identity_encryption_key: *identity.pub_id.encryption.as_bytes(),
|
||||
ephemeral_public: *result.ephemeral_public.as_bytes(),
|
||||
used_one_time_pre_key_id: result.used_one_time_pre_key_id,
|
||||
ephemeral_public,
|
||||
used_one_time_pre_key_id: self.x3dh_used_otpk_id,
|
||||
ratchet_message: encrypted,
|
||||
};
|
||||
bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
@@ -210,7 +215,7 @@ impl WasmSession {
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
let ratchet: RatchetState = bincode::deserialize(&bytes)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
Ok(WasmSession { ratchet })
|
||||
Ok(WasmSession { ratchet, x3dh_ephemeral_public: None, x3dh_used_otpk_id: None })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,3 +620,126 @@ pub fn create_sender_key_from_distribution(
|
||||
let encoded = bincode::serialize(&sender_key).unwrap_or_default();
|
||||
Ok(hex::encode(encoded))
|
||||
}
|
||||
|
||||
// Tests live in warzone-protocol to avoid js-sys dependency issues.
|
||||
// See warzone-protocol/src/x3dh.rs tests for web-client simulation.
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn web_client_to_web_client() {
|
||||
// === Alice (sender) ===
|
||||
let mut alice = WasmIdentity::new();
|
||||
let alice_seed = alice.seed_hex();
|
||||
let alice_spk = alice.spk_secret_hex();
|
||||
let alice_bundle = alice.bundle_bytes().unwrap();
|
||||
|
||||
// === Bob (receiver) ===
|
||||
let mut bob = WasmIdentity::new();
|
||||
let bob_seed = bob.seed_hex();
|
||||
let bob_spk = bob.spk_secret_hex();
|
||||
let bob_bundle = bob.bundle_bytes().unwrap();
|
||||
|
||||
println!("Alice fp: {}", alice.fingerprint());
|
||||
println!("Bob fp: {}", bob.fingerprint());
|
||||
println!("Alice SPK secret: {}...", &alice_spk[..16]);
|
||||
println!("Bob SPK secret: {}...", &bob_spk[..16]);
|
||||
|
||||
// === Alice sends to Bob (exactly like the web JS) ===
|
||||
// 1. Alice creates session from Bob's bundle
|
||||
let mut alice_session = WasmSession::initiate(&alice, &bob_bundle).unwrap();
|
||||
|
||||
// 2. Alice encrypts with key exchange
|
||||
let wire_bytes = alice_session
|
||||
.encrypt_key_exchange_with_id(&alice, &bob_bundle, "hello bob", "msg-001")
|
||||
.unwrap();
|
||||
|
||||
println!("Wire message size: {} bytes", wire_bytes.len());
|
||||
|
||||
// === Bob receives and decrypts (exactly like handleIncomingMessage) ===
|
||||
// First try: decrypt_wire_message with null session (handles KeyExchange)
|
||||
let result = decrypt_wire_message(&bob_seed, &bob_spk, &wire_bytes, None);
|
||||
|
||||
match result {
|
||||
Ok(json_str) => {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
|
||||
println!("Decrypt SUCCESS: {}", json_str);
|
||||
assert_eq!(parsed["text"].as_str().unwrap(), "hello bob");
|
||||
assert!(parsed["new_session"].as_bool().unwrap());
|
||||
println!("Session data present: {}", parsed["session_data"].as_str().is_some());
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("Decrypt FAILED: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that restored session (from base64) can decrypt subsequent messages.
|
||||
#[test]
|
||||
fn web_client_session_continuity() {
|
||||
let mut alice = WasmIdentity::new();
|
||||
let mut bob = WasmIdentity::new();
|
||||
let bob_seed = bob.seed_hex();
|
||||
let bob_spk = bob.spk_secret_hex();
|
||||
let bob_bundle = bob.bundle_bytes().unwrap();
|
||||
|
||||
// Alice sends first message (KeyExchange)
|
||||
let mut alice_session = WasmSession::initiate(&alice, &bob_bundle).unwrap();
|
||||
let wire1 = alice_session
|
||||
.encrypt_key_exchange_with_id(&alice, &bob_bundle, "msg one", "id-1")
|
||||
.unwrap();
|
||||
|
||||
// Bob decrypts first message
|
||||
let result1 = decrypt_wire_message(&bob_seed, &bob_spk, &wire1, None).unwrap();
|
||||
let parsed1: serde_json::Value = serde_json::from_str(&result1).unwrap();
|
||||
assert_eq!(parsed1["text"].as_str().unwrap(), "msg one");
|
||||
let bob_session_data = parsed1["session_data"].as_str().unwrap().to_string();
|
||||
|
||||
// Alice sends second message (regular Message, not KeyExchange)
|
||||
let alice_session_data = alice_session.save().unwrap();
|
||||
let mut alice_session2 = WasmSession::restore(&alice_session_data).unwrap();
|
||||
let wire2 = alice_session2
|
||||
.encrypt_with_id(&alice, "msg two", "id-2")
|
||||
.unwrap();
|
||||
|
||||
// Bob decrypts second message using saved session
|
||||
let result2 = decrypt_wire_message(&bob_seed, &bob_spk, &wire2, Some(bob_session_data)).unwrap();
|
||||
let parsed2: serde_json::Value = serde_json::from_str(&result2).unwrap();
|
||||
assert_eq!(parsed2["text"].as_str().unwrap(), "msg two");
|
||||
}
|
||||
|
||||
/// Test bidirectional: Alice sends to Bob, Bob sends to Alice.
|
||||
#[test]
|
||||
fn web_client_bidirectional() {
|
||||
let mut alice = WasmIdentity::new();
|
||||
let alice_seed = alice.seed_hex();
|
||||
let alice_spk = alice.spk_secret_hex();
|
||||
let alice_bundle = alice.bundle_bytes().unwrap();
|
||||
|
||||
let mut bob = WasmIdentity::new();
|
||||
let bob_seed = bob.seed_hex();
|
||||
let bob_spk = bob.spk_secret_hex();
|
||||
let bob_bundle = bob.bundle_bytes().unwrap();
|
||||
|
||||
// Alice → Bob
|
||||
let mut a_session = WasmSession::initiate(&alice, &bob_bundle).unwrap();
|
||||
let wire_a2b = a_session
|
||||
.encrypt_key_exchange_with_id(&alice, &bob_bundle, "hi bob", "a1")
|
||||
.unwrap();
|
||||
let r1 = decrypt_wire_message(&bob_seed, &bob_spk, &wire_a2b, None).unwrap();
|
||||
let p1: serde_json::Value = serde_json::from_str(&r1).unwrap();
|
||||
assert_eq!(p1["text"].as_str().unwrap(), "hi bob");
|
||||
|
||||
// Bob → Alice
|
||||
let mut b_session = WasmSession::initiate(&bob, &alice_bundle).unwrap();
|
||||
let wire_b2a = b_session
|
||||
.encrypt_key_exchange_with_id(&bob, &alice_bundle, "hi alice", "b1")
|
||||
.unwrap();
|
||||
let r2 = decrypt_wire_message(&alice_seed, &alice_spk, &wire_b2a, None).unwrap();
|
||||
let p2: serde_json::Value = serde_json::from_str(&r2).unwrap();
|
||||
assert_eq!(p2["text"].as_str().unwrap(), "hi alice");
|
||||
}
|
||||
}
|
||||
|
||||
8
warzone/deploy/federation-kh3rad3ree.json
Normal file
8
warzone/deploy/federation-kh3rad3ree.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"server_id": "kh3rad3ree",
|
||||
"shared_secret": "7cfe41395062d939a36d9debe7d70f528ccd2efaccddca139c19603fe40df8f4",
|
||||
"peer": {
|
||||
"id": "mequ",
|
||||
"url": "http://10.66.66.129:7700"
|
||||
}
|
||||
}
|
||||
8
warzone/deploy/federation-mequ.json
Normal file
8
warzone/deploy/federation-mequ.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"server_id": "mequ",
|
||||
"shared_secret": "7cfe41395062d939a36d9debe7d70f528ccd2efaccddca139c19603fe40df8f4",
|
||||
"peer": {
|
||||
"id": "kh3rad3ree",
|
||||
"url": "http://10.66.66.253:7700"
|
||||
}
|
||||
}
|
||||
6
warzone/deploy/journald-warzone.conf
Normal file
6
warzone/deploy/journald-warzone.conf
Normal file
@@ -0,0 +1,6 @@
|
||||
# /etc/systemd/journald.conf.d/warzone.conf
|
||||
# Cap journal storage to avoid filling disk on mequ
|
||||
[Journal]
|
||||
SystemMaxUse=50M
|
||||
SystemMaxFileSize=10M
|
||||
MaxRetentionSec=7day
|
||||
53
warzone/deploy/setup.sh
Executable file
53
warzone/deploy/setup.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Setup script — run as root on each server.
|
||||
# Usage: ./setup.sh <mequ|kh3rad3ree>
|
||||
|
||||
HOSTNAME="${1:-}"
|
||||
if [ -z "$HOSTNAME" ] || { [ "$HOSTNAME" != "mequ" ] && [ "$HOSTNAME" != "kh3rad3ree" ]; }; then
|
||||
echo "Usage: $0 <mequ|kh3rad3ree>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Setting up featherChat on $HOSTNAME ==="
|
||||
|
||||
# Create warzone user if it doesn't exist
|
||||
if ! id warzone &>/dev/null; then
|
||||
echo "[1/4] Creating warzone user..."
|
||||
useradd -r -m -s /bin/bash warzone
|
||||
else
|
||||
echo "[1/4] User warzone already exists"
|
||||
fi
|
||||
|
||||
# Create data directory
|
||||
echo "[2/4] Creating directories..."
|
||||
mkdir -p /home/warzone/data
|
||||
chown -R warzone:warzone /home/warzone
|
||||
|
||||
# Copy binaries
|
||||
echo "[3/4] Installing binaries..."
|
||||
cp warzone-server warzone-client /home/warzone/
|
||||
chmod +x /home/warzone/warzone-server /home/warzone/warzone-client
|
||||
cp "federation-${HOSTNAME}.json" /home/warzone/federation.json
|
||||
chown warzone:warzone /home/warzone/warzone-server /home/warzone/warzone-client /home/warzone/federation.json
|
||||
|
||||
# Install systemd service + journald log cap
|
||||
echo "[4/5] Installing systemd service..."
|
||||
cp warzone-server.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable warzone-server
|
||||
|
||||
echo "[5/5] Capping journal logs (50MB max, 7 day retention)..."
|
||||
mkdir -p /etc/systemd/journald.conf.d
|
||||
cp journald-warzone.conf /etc/systemd/journald.conf.d/warzone.conf
|
||||
systemctl restart systemd-journald
|
||||
# Vacuum existing logs
|
||||
journalctl --vacuum-size=50M 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo "Start: systemctl start warzone-server"
|
||||
echo "Status: systemctl status warzone-server"
|
||||
echo "Logs: journalctl -u warzone-server -f"
|
||||
echo "Stop: systemctl stop warzone-server"
|
||||
27
warzone/deploy/warzone-server.service
Normal file
27
warzone/deploy/warzone-server.service
Normal file
@@ -0,0 +1,27 @@
|
||||
[Unit]
|
||||
Description=Warzone Messenger Server (featherChat)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=warzone
|
||||
Group=warzone
|
||||
WorkingDirectory=/home/warzone
|
||||
ExecStart=/home/warzone/warzone-server --bind 0.0.0.0:7700 --data-dir /home/warzone/data --federation /home/warzone/federation.json
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
LimitNOFILE=65536
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/warzone/data
|
||||
PrivateTmp=yes
|
||||
|
||||
# Environment — warn-only to minimize disk usage (set to info for debugging)
|
||||
Environment=RUST_LOG=warn,warzone_server::federation=info
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
483
warzone/scripts/build-linux.sh
Executable file
483
warzone/scripts/build-linux.sh
Executable file
@@ -0,0 +1,483 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build featherChat Linux x86_64 release binaries using a Hetzner Cloud VPS.
|
||||
# Prerequisites: hcloud CLI authenticated, SSH key "wz" registered.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-linux.sh --prepare Create VM, install deps, upload source
|
||||
# ./scripts/build-linux.sh --build Build release binaries on the VM
|
||||
# ./scripts/build-linux.sh --transfer Download binaries from VM to local
|
||||
# ./scripts/build-linux.sh --destroy Delete the VM
|
||||
# ./scripts/build-linux.sh --all Run prepare + build + transfer (no destroy)
|
||||
# ./scripts/build-linux.sh --upload Re-upload source to existing VM
|
||||
#
|
||||
# The VM persists between steps so you can iterate on build errors.
|
||||
# Reuses the same WZP builder VM if it already exists.
|
||||
|
||||
VM_NAME="fc-builder"
|
||||
SSH_KEY_NAME="wz"
|
||||
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
|
||||
SERVER_TYPE="cx33"
|
||||
IMAGE="debian-12"
|
||||
REMOTE_USER="root"
|
||||
OUTPUT_DIR="target/linux-x86_64"
|
||||
PROJECT_DIR="/Users/manwe/CascadeProjects/featherChat/warzone"
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10"
|
||||
|
||||
# Binaries to build
|
||||
BINS="warzone-server warzone-client"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
get_vm_ip() {
|
||||
local ip
|
||||
ip=$(hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | tr -d ' ')
|
||||
if [ -z "$ip" ]; then
|
||||
echo "ERROR: No VM '$VM_NAME' found. Run --prepare first." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$ip"
|
||||
}
|
||||
|
||||
ssh_cmd() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@"
|
||||
}
|
||||
|
||||
scp_to() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$@" "$REMOTE_USER@$ip:/root/" 2>/dev/null
|
||||
}
|
||||
|
||||
scp_from() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
# args: remote_path local_path
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:$1" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --prepare: Create VM, install deps, upload source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_prepare() {
|
||||
# Check if VM already exists
|
||||
local existing
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
|
||||
if [ -n "$existing" ]; then
|
||||
echo "VM already exists: $existing"
|
||||
echo "Reusing it. Uploading fresh source..."
|
||||
do_upload
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[1/5] Creating Hetzner VM: $VM_NAME ($SERVER_TYPE, $IMAGE)..."
|
||||
hcloud server create \
|
||||
--name "$VM_NAME" \
|
||||
--type "$SERVER_TYPE" \
|
||||
--image "$IMAGE" \
|
||||
--ssh-key "$SSH_KEY_NAME" \
|
||||
--location fsn1 \
|
||||
--quiet
|
||||
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
echo " VM: $VM_NAME @ $ip"
|
||||
|
||||
# Wait for SSH
|
||||
echo "[2/5] Waiting for SSH..."
|
||||
for i in $(seq 1 30); do
|
||||
if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "echo ok" &>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Install build dependencies
|
||||
echo "[3/5] Installing build dependencies..."
|
||||
ssh_cmd "apt-get update -qq && apt-get install -y -qq \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
curl \
|
||||
git \
|
||||
> /dev/null 2>&1"
|
||||
|
||||
# Install Rust + wasm-pack
|
||||
echo "[4/5] Installing Rust + wasm-pack..."
|
||||
ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1"
|
||||
ssh_cmd "source ~/.cargo/env && rustup target add wasm32-unknown-unknown > /dev/null 2>&1"
|
||||
ssh_cmd "source ~/.cargo/env && cargo install wasm-pack > /dev/null 2>&1 || true"
|
||||
|
||||
# Upload source
|
||||
echo "[5/5] Uploading source code..."
|
||||
do_upload
|
||||
|
||||
echo ""
|
||||
echo "=== VM Ready ==="
|
||||
echo "IP: $ip"
|
||||
echo "SSH: ssh -i $SSH_KEY_PATH root@$ip"
|
||||
echo ""
|
||||
echo "Next: ./scripts/build-linux.sh --build"
|
||||
}
|
||||
|
||||
do_upload() {
|
||||
echo " Creating source tarball..."
|
||||
|
||||
# Create tarball excluding build artifacts and non-essential files
|
||||
tar czf /tmp/fc-src.tar.gz \
|
||||
--exclude='target' \
|
||||
--exclude='.git' \
|
||||
--exclude='.claude' \
|
||||
--exclude='warzone-phone' \
|
||||
--exclude='notes' \
|
||||
-C "$PROJECT_DIR" . 2>/dev/null
|
||||
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
local size
|
||||
size=$(du -h /tmp/fc-src.tar.gz | cut -f1)
|
||||
echo " Uploading $size to VM..."
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" /tmp/fc-src.tar.gz "$REMOTE_USER@$ip:/root/fc-src.tar.gz" 2>/dev/null
|
||||
ssh_cmd "rm -rf /root/featherChat && mkdir -p /root/featherChat && tar xzf /root/fc-src.tar.gz -C /root/featherChat" 2>/dev/null
|
||||
rm -f /tmp/fc-src.tar.gz
|
||||
echo " Source uploaded."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --build: Build release binaries on the VM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_build() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
echo "=== Building on $ip ==="
|
||||
|
||||
local bin_args=""
|
||||
for bin in $BINS; do
|
||||
bin_args="$bin_args --bin $bin"
|
||||
done
|
||||
|
||||
echo "[1/3] Building WASM (warzone-wasm)..."
|
||||
ssh_cmd "source ~/.cargo/env && cd /root/featherChat && wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg 2>&1" | tail -5
|
||||
|
||||
echo ""
|
||||
echo "[2/3] Building: $BINS"
|
||||
ssh_cmd "source ~/.cargo/env && cd /root/featherChat && cargo build --release $bin_args 2>&1"
|
||||
|
||||
echo ""
|
||||
echo "[3/3] Verifying binaries..."
|
||||
for bin in $BINS; do
|
||||
ssh_cmd "ls -lh /root/featherChat/target/release/$bin" 2>/dev/null
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Build Complete ==="
|
||||
echo "Next: ./scripts/build-linux.sh --transfer"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --transfer: Download binaries from VM to local
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_transfer() {
|
||||
local ip
|
||||
ip=$(get_vm_ip)
|
||||
echo "=== Downloading binaries from $ip ==="
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
for bin in $BINS; do
|
||||
echo " $bin..."
|
||||
scp_from "/root/featherChat/target/release/$bin" "$OUTPUT_DIR/$bin"
|
||||
done
|
||||
|
||||
# Also grab the embedded web client HTML if it exists
|
||||
if ssh_cmd "test -f /root/featherChat/target/release/warzone-server" 2>/dev/null; then
|
||||
echo " federation.example.json..."
|
||||
scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:/root/featherChat/federation.example.json" "$OUTPUT_DIR/federation.example.json" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Transfer Complete ==="
|
||||
ls -lh "$OUTPUT_DIR"/warzone-*
|
||||
echo ""
|
||||
echo "Deploy with:"
|
||||
echo " scp $OUTPUT_DIR/warzone-server $OUTPUT_DIR/warzone-client user@mequ:~/warzone/"
|
||||
echo ""
|
||||
echo "Run on server:"
|
||||
echo " ./warzone-server --bind 0.0.0.0:7700"
|
||||
echo " ./warzone-server --bind 0.0.0.0:7700 --federation federation.json"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --destroy: Delete the VM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_destroy() {
|
||||
local existing
|
||||
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true)
|
||||
if [ -z "$existing" ]; then
|
||||
echo "No VM '$VM_NAME' to destroy."
|
||||
return
|
||||
fi
|
||||
echo "Deleting VM: $VM_NAME"
|
||||
hcloud server delete "$VM_NAME"
|
||||
echo "Done."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --deploy: Transfer + deploy to production server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_deploy() {
|
||||
local deploy_host="${2:-}"
|
||||
if [ -z "$deploy_host" ]; then
|
||||
echo "Usage: $0 --deploy <user@host> [--federation <config.json>]"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " $0 --deploy root@mequ.example.com"
|
||||
echo " $0 --deploy root@mequ.example.com --federation federation.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Deploying to $deploy_host ==="
|
||||
|
||||
# Ensure binaries exist locally
|
||||
if [ ! -f "$OUTPUT_DIR/warzone-server" ]; then
|
||||
echo "ERROR: No binaries in $OUTPUT_DIR. Run --build and --transfer first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/3] Uploading binaries..."
|
||||
scp "$OUTPUT_DIR/warzone-server" "$OUTPUT_DIR/warzone-client" "$deploy_host:~/warzone/"
|
||||
|
||||
# Upload federation config if specified
|
||||
local fed_arg=""
|
||||
if [ "${3:-}" = "--federation" ] && [ -n "${4:-}" ]; then
|
||||
echo "[2/3] Uploading federation config..."
|
||||
scp "$4" "$deploy_host:~/warzone/federation.json"
|
||||
fed_arg="--federation ~/warzone/federation.json"
|
||||
else
|
||||
echo "[2/3] No federation config (standalone mode)"
|
||||
fi
|
||||
|
||||
echo "[3/3] Restarting server..."
|
||||
ssh "$deploy_host" "pkill warzone-server || true; sleep 1; cd ~/warzone && nohup ./warzone-server --bind 0.0.0.0:7700 $fed_arg > server.log 2>&1 &"
|
||||
|
||||
echo ""
|
||||
echo "=== Deployed ==="
|
||||
echo "Server running at $deploy_host:7700"
|
||||
echo "Logs: ssh $deploy_host 'tail -f ~/warzone/server.log'"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Production servers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROD_SERVERS=(
|
||||
"root@mequ"
|
||||
"root@kh3rad3ree"
|
||||
)
|
||||
PROD_SERVICE="warzone-server"
|
||||
PROD_BIN_DIR="/home/warzone"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --update <host>: Stop service, upload binaries, restart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_update() {
|
||||
local host="${1:-}"
|
||||
if [ -z "$host" ]; then
|
||||
echo "Usage: $0 --update <user@host>"
|
||||
echo " or: $0 --update-all"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$OUTPUT_DIR/warzone-server" ]; then
|
||||
echo "ERROR: No binaries in $OUTPUT_DIR. Run --all first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Updating $host ==="
|
||||
|
||||
echo "[1/4] Stopping service..."
|
||||
ssh "$host" "systemctl stop $PROD_SERVICE 2>/dev/null || true"
|
||||
|
||||
echo "[2/4] Uploading binaries..."
|
||||
scp "$OUTPUT_DIR/warzone-server" "$OUTPUT_DIR/warzone-client" "$host:$PROD_BIN_DIR/"
|
||||
ssh "$host" "chmod +x $PROD_BIN_DIR/warzone-server $PROD_BIN_DIR/warzone-client"
|
||||
|
||||
echo "[3/4] Starting service..."
|
||||
ssh "$host" "systemctl start $PROD_SERVICE"
|
||||
|
||||
echo "[4/4] Verifying..."
|
||||
sleep 1
|
||||
local status
|
||||
status=$(ssh "$host" "systemctl is-active $PROD_SERVICE 2>/dev/null" || true)
|
||||
if [ "$status" = "active" ]; then
|
||||
echo " $host: $PROD_SERVICE is running"
|
||||
else
|
||||
echo " WARNING: $host: $PROD_SERVICE status = $status"
|
||||
echo " Check logs: ssh $host 'journalctl -u $PROD_SERVICE -n 20'"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --update-all: Update all production servers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_update_all() {
|
||||
if [ ! -f "$OUTPUT_DIR/warzone-server" ]; then
|
||||
echo "ERROR: No binaries in $OUTPUT_DIR. Run --all first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Updating all production servers ==="
|
||||
echo ""
|
||||
for host in "${PROD_SERVERS[@]}"; do
|
||||
do_update "$host"
|
||||
done
|
||||
echo "=== All servers updated ==="
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --status: Check service status on all production servers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_status() {
|
||||
echo "=== Production server status ==="
|
||||
for host in "${PROD_SERVERS[@]}"; do
|
||||
local status
|
||||
status=$(ssh "$host" "systemctl is-active $PROD_SERVICE 2>/dev/null" || echo "unreachable")
|
||||
local uptime
|
||||
uptime=$(ssh "$host" "systemctl show $PROD_SERVICE --property=ActiveEnterTimestamp --value 2>/dev/null" || echo "?")
|
||||
printf " %-20s %s (since %s)\n" "$host" "$status" "$uptime"
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Check federation
|
||||
for host in "${PROD_SERVERS[@]}"; do
|
||||
local addr
|
||||
addr=$(echo "$host" | cut -d@ -f2)
|
||||
echo " Federation ($addr):"
|
||||
curl -s "http://$addr:7700/v1/federation/status" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (unreachable)"
|
||||
echo ""
|
||||
done
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --logs <host>: Tail logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_logs() {
|
||||
local host="${1:-${PROD_SERVERS[0]}}"
|
||||
echo "=== Logs from $host ==="
|
||||
ssh "$host" "journalctl -u $PROD_SERVICE -f --no-pager"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --ship: Build + deploy to all servers + destroy VM (full pipeline)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
do_ship() {
|
||||
echo "========================================"
|
||||
echo " SHIPPING featherChat to production"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
do_prepare
|
||||
echo ""
|
||||
do_build
|
||||
echo ""
|
||||
do_transfer
|
||||
echo ""
|
||||
do_update_all
|
||||
echo ""
|
||||
do_destroy
|
||||
echo ""
|
||||
do_status
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " SHIP COMPLETE"
|
||||
echo "========================================"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
case "${1:-}" in
|
||||
--prepare)
|
||||
do_prepare
|
||||
;;
|
||||
--build)
|
||||
do_build
|
||||
;;
|
||||
--transfer)
|
||||
do_transfer
|
||||
;;
|
||||
--destroy)
|
||||
do_destroy
|
||||
;;
|
||||
--deploy)
|
||||
do_deploy "$@"
|
||||
;;
|
||||
--update)
|
||||
do_update "${2:-}"
|
||||
;;
|
||||
--update-all)
|
||||
do_update_all
|
||||
;;
|
||||
--status)
|
||||
do_status
|
||||
;;
|
||||
--logs)
|
||||
do_logs "${2:-}"
|
||||
;;
|
||||
--all)
|
||||
do_prepare
|
||||
do_build
|
||||
do_transfer
|
||||
echo ""
|
||||
echo "VM is still running. Destroy with: ./scripts/build-linux.sh --destroy"
|
||||
;;
|
||||
--ship)
|
||||
do_ship
|
||||
;;
|
||||
--upload)
|
||||
do_upload
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 <command> [args]"
|
||||
echo ""
|
||||
echo "One command:"
|
||||
echo " --ship Build + deploy to all servers + destroy VM"
|
||||
echo ""
|
||||
echo "Build (Hetzner VM):"
|
||||
echo " --prepare Create VM, install deps, upload source"
|
||||
echo " --build Build release binaries"
|
||||
echo " --transfer Download binaries to $OUTPUT_DIR"
|
||||
echo " --destroy Delete the build VM"
|
||||
echo " --all prepare + build + transfer (VM persists)"
|
||||
echo " --upload Re-upload source to existing VM"
|
||||
echo ""
|
||||
echo "Deploy:"
|
||||
echo " --update <user@host> Stop service, upload binaries, restart"
|
||||
echo " --update-all Update mequ + kh3rad3ree"
|
||||
echo " --deploy <user@host> First-time deploy (upload + start)"
|
||||
echo ""
|
||||
echo "Monitor:"
|
||||
echo " --status Check service status on all servers"
|
||||
echo " --logs [user@host] Tail server logs (default: mequ)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user