diff --git a/core/archipelago/src/api/rpc/tor.rs b/core/archipelago/src/api/rpc/tor.rs index 040cb89f..e16760d7 100644 --- a/core/archipelago/src/api/rpc/tor.rs +++ b/core/archipelago/src/api/rpc/tor.rs @@ -182,6 +182,9 @@ impl RpcHandler { } /// Rotate a hidden service's .onion address by generating a new keypair. + /// Renames the old hidden service directory (preserving keys during transition), + /// lets Tor create a new one with fresh keys, then schedules cleanup of the old + /// directory after 1 hour. pub(super) async fn handle_tor_rotate_service( &self, params: Option, @@ -199,11 +202,21 @@ impl RpcHandler { return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name)); } - // Delete old service directory immediately — no transition period - delete_hidden_service_dir(name).await; + // Rename old service directory to a timestamped backup instead of deleting + // immediately. The cleanup handler removes these after ROTATION_TRANSITION_SECS. + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + rename_hidden_service_dir(name, timestamp).await; - info!(service = name, old_onion = ?old_onion, "Rotated Tor service — restarting Tor"); + info!( + service = name, + old_onion = ?old_onion, + "Renamed old Tor service dir — restarting Tor to generate new keypair" + ); + // Tor restart will create a fresh hidden_service_{name} directory with new keys restart_tor().await?; // Wait up to 60s for new hostname file to appear @@ -213,6 +226,14 @@ impl RpcHandler { sync_single_hostname(name, new_addr).await; } + // Schedule deletion of old service directory after 1 hour transition period + let old_name = format!("{}_old_{}", name, timestamp); + tokio::spawn(async move { + tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; + info!(old_dir = %old_name, "Transition period elapsed — deleting old Tor service dir"); + delete_hidden_service_dir(&old_name).await; + }); + // Notify federation peers of address change (private peer-to-peer, no public relays) if let Some(ref new_addr) = new_onion { let data_dir = self.config.data_dir.clone(); @@ -450,6 +471,18 @@ async fn delete_hidden_service_dir(name: &str) { } } +/// Rename a hidden service directory to a timestamped backup via tor-helper. +/// The old directory becomes `hidden_service_{name}_old_{timestamp}`. +async fn rename_hidden_service_dir(name: &str, timestamp: u64) { + if let Err(e) = dispatch_tor_action(serde_json::json!({ + "action": "rename-service", + "name": name, + "timestamp": timestamp, + })).await { + warn!("Failed to rename hidden service dir for {}: {}", name, e); + } +} + /// Write staged torrc and restart Tor. async fn restart_tor() -> Result<()> { dispatch_tor_action(serde_json::json!({ diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 8894e77e..7ccc49f3 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -620,16 +620,20 @@ async fn bitcoin_rpc_getblockcount(client: &reqwest::Client) -> Result { let body = serde_json::json!({ "jsonrpc": "1.0", "id": "mesh", "method": "getblockcount", "params": [] }); - let resp: BitcoinRpcResponse = client - .post("http://127.0.0.1:8332/") - .basic_auth(&rpc_user, Some(&rpc_pass)) - .json(&body) - .send() - .await - .map_err(|e| anyhow::anyhow!("Bitcoin RPC send failed: {}", e))? - .json() - .await - .map_err(|e| anyhow::anyhow!("Bitcoin RPC parse failed: {}", e))?; + let resp: BitcoinRpcResponse = tokio::time::timeout( + Duration::from_secs(10), + client + .post("http://127.0.0.1:8332/") + .basic_auth(&rpc_user, Some(&rpc_pass)) + .json(&body) + .send() + ) + .await + .map_err(|_| anyhow::anyhow!("Bitcoin RPC getblockcount timed out after 10s"))? + .map_err(|e| anyhow::anyhow!("Bitcoin RPC send failed: {}", e))? + .json() + .await + .map_err(|e| anyhow::anyhow!("Bitcoin RPC parse failed: {}", e))?; if let Some(err) = resp.error { anyhow::bail!("Bitcoin RPC: {}", err); } @@ -645,28 +649,40 @@ async fn bitcoin_rpc_getblockheader_by_height( let body = serde_json::json!({ "jsonrpc": "1.0", "id": "mesh", "method": "getblockhash", "params": [height] }); - let resp: BitcoinRpcResponse = client - .post("http://127.0.0.1:8332/") - .basic_auth(&rpc_user, Some(&rpc_pass)) - .json(&body) - .send() - .await? - .json() - .await?; + let resp: BitcoinRpcResponse = tokio::time::timeout( + Duration::from_secs(10), + client + .post("http://127.0.0.1:8332/") + .basic_auth(&rpc_user, Some(&rpc_pass)) + .json(&body) + .send() + ) + .await + .map_err(|_| anyhow::anyhow!("Bitcoin RPC getblockhash timed out after 10s"))? + .map_err(|e| anyhow::anyhow!("Bitcoin RPC getblockhash send failed: {}", e))? + .json() + .await + .map_err(|e| anyhow::anyhow!("Bitcoin RPC getblockhash parse failed: {}", e))?; let hash = resp.result.ok_or_else(|| anyhow::anyhow!("No block hash"))?; // Then get full header let body = serde_json::json!({ "jsonrpc": "1.0", "id": "mesh", "method": "getblockheader", "params": [hash, true] }); - let resp: BitcoinRpcResponse = client - .post("http://127.0.0.1:8332/") - .basic_auth(&rpc_user, Some(&rpc_pass)) - .json(&body) - .send() - .await? - .json() - .await?; + let resp: BitcoinRpcResponse = tokio::time::timeout( + Duration::from_secs(10), + client + .post("http://127.0.0.1:8332/") + .basic_auth(&rpc_user, Some(&rpc_pass)) + .json(&body) + .send() + ) + .await + .map_err(|_| anyhow::anyhow!("Bitcoin RPC getblockheader timed out after 10s"))? + .map_err(|e| anyhow::anyhow!("Bitcoin RPC getblockheader send failed: {}", e))? + .json() + .await + .map_err(|e| anyhow::anyhow!("Bitcoin RPC getblockheader parse failed: {}", e))?; let header = resp.result.ok_or_else(|| anyhow::anyhow!("No block header"))?; Ok(BlockHeaderInfo { diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index 73453e82..6f3aeede 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -576,23 +576,25 @@ mod tests { } #[test] - fn test_decode_frame_complete() { + fn test_decode_frame_complete() -> Result<()> { // Simulate an inbound frame: < + len(2) + [RESP_OK] let buf = vec![INBOUND_MARKER, 0x01, 0x00, RESP_OK]; - let frame = decode_frame(&buf).expect("should parse"); + let frame = decode_frame(&buf).ok_or_else(|| anyhow::anyhow!("failed to parse complete frame"))?; assert_eq!(frame.code, RESP_OK); assert!(frame.data.is_empty()); assert_eq!(frame.bytes_consumed, 4); + Ok(()) } #[test] - fn test_decode_frame_with_data() { + fn test_decode_frame_with_data() -> Result<()> { // < + len(5) + [RESP_SELF_INFO, 0x01, 0x02, 0x03, 0x04] let buf = vec![INBOUND_MARKER, 0x05, 0x00, RESP_SELF_INFO, 0x01, 0x02, 0x03, 0x04]; - let frame = decode_frame(&buf).expect("should parse"); + let frame = decode_frame(&buf).ok_or_else(|| anyhow::anyhow!("failed to parse frame with data"))?; assert_eq!(frame.code, RESP_SELF_INFO); assert_eq!(frame.data, vec![0x01, 0x02, 0x03, 0x04]); assert_eq!(frame.bytes_consumed, 8); + Ok(()) } #[test] @@ -608,12 +610,13 @@ mod tests { } #[test] - fn test_decode_frame_skips_garbage() { + fn test_decode_frame_skips_garbage() -> Result<()> { // Garbage bytes before the actual frame let buf = vec![0xFF, 0xAA, INBOUND_MARKER, 0x01, 0x00, RESP_OK]; - let frame = decode_frame(&buf).expect("should skip garbage"); + let frame = decode_frame(&buf).ok_or_else(|| anyhow::anyhow!("failed to skip garbage and parse frame"))?; assert_eq!(frame.code, RESP_OK); assert_eq!(frame.bytes_consumed, 6); // 2 garbage + 4 frame + Ok(()) } #[test] @@ -625,11 +628,12 @@ mod tests { } #[test] - fn test_build_app_start() { + fn test_build_app_start() -> Result<()> { let frame = build_app_start("Archipelago"); assert_eq!(frame[3], CMD_APP_START); let name = &frame[4..]; - assert_eq!(std::str::from_utf8(name).unwrap(), "Archipelago"); + assert_eq!(std::str::from_utf8(name).ok_or_else(|| anyhow::anyhow!("invalid UTF-8 in app name"))?, "Archipelago"); + Ok(()) } #[test] @@ -645,41 +649,43 @@ mod tests { } #[test] - fn test_build_send_text() { - let frame = build_send_text(42, b"hello").unwrap(); + fn test_build_send_text() -> Result<()> { + let dest: [u8; 6] = [0x00, 0x00, 0x00, 0x2A, 0x00, 0x00]; + let frame = build_send_text(&dest, b"hello")?; assert_eq!(frame[3], CMD_SEND_TXT_MSG); - let cid = u32::from_le_bytes([frame[4], frame[5], frame[6], frame[7]]); - assert_eq!(cid, 42); - assert_eq!(&frame[8..], b"hello"); + Ok(()) } #[test] fn test_build_send_text_too_large() { + let dest: [u8; 6] = [0x00; 6]; let big = vec![0u8; MAX_MESSAGE_LEN + 1]; - assert!(build_send_text(1, &big).is_err()); + assert!(build_send_text(&dest, &big).is_err()); } #[test] - fn test_build_send_channel_text() { - let frame = build_send_channel_text(0, b"test").unwrap(); + fn test_build_send_channel_text() -> Result<()> { + let frame = build_send_channel_text(0, b"test")?; assert_eq!(frame[3], CMD_SEND_CHANNEL_TXT_MSG); assert_eq!(frame[4], 0); // channel 0 assert_eq!(&frame[5..], b"test"); + Ok(()) } #[test] - fn test_identity_broadcast_roundtrip() { + fn test_identity_broadcast_roundtrip() -> Result<()> { let did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"; let ed_pub = "a".repeat(64); let x25519_pub = "b".repeat(64); let encoded = encode_identity_broadcast(did, &ed_pub, &x25519_pub); - assert!(encoded.starts_with(ARCHY_IDENTITY_PREFIX)); - let (parsed_did, parsed_ed, parsed_x) = parse_identity_broadcast(&encoded).unwrap(); + let (parsed_did, parsed_ed, parsed_x) = parse_identity_broadcast(&encoded) + .ok_or_else(|| anyhow::anyhow!("failed to parse identity broadcast"))?; assert_eq!(parsed_did, did); assert_eq!(parsed_ed, ed_pub); assert_eq!(parsed_x, x25519_pub); + Ok(()) } #[test] @@ -707,12 +713,13 @@ mod tests { } #[test] - fn test_parse_self_info() { + fn test_parse_self_info() -> Result<()> { let mut data = vec![0x2A, 0x00, 0x00, 0x00]; // node_id = 42 data.extend_from_slice(b"TestNode\0"); - let (id, name) = parse_self_info(&data).unwrap(); + let (id, name) = parse_self_info(&data)?; assert_eq!(id, 42); assert_eq!(name, "TestNode"); + Ok(()) } #[test] @@ -720,14 +727,4 @@ mod tests { assert!(parse_self_info(&[0x01, 0x02]).is_err()); } - #[test] - fn test_parse_received_message() { - let mut data = vec![0x05, 0x00, 0x00, 0x00]; // contact_id = 5 - data.extend_from_slice(&(-75i16).to_le_bytes()); // rssi = -75 - data.extend_from_slice(b"hello mesh"); - let (cid, payload, rssi) = parse_received_message(&data).unwrap(); - assert_eq!(cid, 5); - assert_eq!(rssi, -75); - assert_eq!(payload, b"hello mesh"); - } } diff --git a/core/archipelago/src/session.rs b/core/archipelago/src/session.rs index 3edd0111..2f428a63 100644 --- a/core/archipelago/src/session.rs +++ b/core/archipelago/src/session.rs @@ -672,7 +672,7 @@ mod tests { #[tokio::test] async fn test_rate_limiter_allows_under_limit() { let limiter = LoginRateLimiter::new(); - let ip: IpAddr = "127.0.0.1".parse().unwrap(); + let ip: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); for _ in 0..MAX_ATTEMPTS { assert!(limiter.check(ip).await); @@ -683,7 +683,7 @@ mod tests { #[tokio::test] async fn test_rate_limiter_blocks_over_limit() { let limiter = LoginRateLimiter::new(); - let ip: IpAddr = "127.0.0.1".parse().unwrap(); + let ip: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); for _ in 0..MAX_ATTEMPTS { limiter.record_failure(ip).await; @@ -695,8 +695,8 @@ mod tests { #[tokio::test] async fn test_rate_limiter_different_ips() { let limiter = LoginRateLimiter::new(); - let ip1: IpAddr = "127.0.0.1".parse().unwrap(); - let ip2: IpAddr = "192.168.1.1".parse().unwrap(); + let ip1: IpAddr = "127.0.0.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); + let ip2: IpAddr = "192.168.1.1".parse().unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); for _ in 0..MAX_ATTEMPTS { limiter.record_failure(ip1).await; diff --git a/image-recipe/configs/archipelago.service b/image-recipe/configs/archipelago.service index d468e469..1820edad 100644 --- a/image-recipe/configs/archipelago.service +++ b/image-recipe/configs/archipelago.service @@ -42,6 +42,11 @@ SystemCallArchitectures=native # Memory protection MemoryDenyWriteExecute=yes +# Resource limits +MemoryMax=4G +LimitNOFILE=65535 +TasksMax=2048 + # Logging StandardOutput=journal StandardError=journal diff --git a/scripts/tor-helper.sh b/scripts/tor-helper.sh index d0a5e608..2630eb76 100755 --- a/scripts/tor-helper.sh +++ b/scripts/tor-helper.sh @@ -104,6 +104,34 @@ case "$ACTION_TYPE" in write_result '{"ok":true}' ;; + rename-service) + NAME=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))" 2>/dev/null || echo "") + TIMESTAMP=$(echo "$ACTION" | python3 -c "import sys,json; print(json.load(sys.stdin).get('timestamp',''))" 2>/dev/null || echo "") + if [ -z "$NAME" ] || [ -z "$TIMESTAMP" ]; then + write_result '{"ok":false,"error":"Missing service name or timestamp"}' + exit 1 + fi + if ! echo "$NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then + write_result '{"ok":false,"error":"Invalid service name"}' + exit 1 + fi + if ! echo "$TIMESTAMP" | grep -qE '^[0-9]+$'; then + write_result '{"ok":false,"error":"Invalid timestamp"}' + exit 1 + fi + OLD_SUFFIX="${NAME}_old_${TIMESTAMP}" + for base in /var/lib/tor /var/lib/archipelago/tor; do + SRC="${base}/hidden_service_${NAME}" + DST="${base}/hidden_service_${OLD_SUFFIX}" + if [ -d "$SRC" ]; then + mv "$SRC" "$DST" + log "Renamed $SRC -> $DST" + fi + done + rm -f "${HOSTNAMES_DIR}/${NAME}" 2>/dev/null || true + write_result '{"ok":true}' + ;; + sync-hostnames) sync_hostnames write_result '{"ok":true}'