diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index cabac697..0d8c7a80 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -83,7 +83,6 @@ zeroize = { version = "1.7", features = ["derive"] } # Mainline DHT (did:dht — BitTorrent DHT for decentralized identity) mainline = "2" zbase32 = "0.1" -simple-dns = "0.7" # Systemd watchdog notification sd-notify = "0.4" diff --git a/core/archipelago/src/api/rpc/network.rs b/core/archipelago/src/api/rpc/network.rs index 72faf980..2b48f2e2 100644 --- a/core/archipelago/src/api/rpc/network.rs +++ b/core/archipelago/src/api/rpc/network.rs @@ -1,7 +1,7 @@ //! RPC handlers for node network visibility and overlay controls. use super::RpcHandler; -use crate::{identity, nostr_discovery, peers}; +use crate::{identity, peers}; use crate::container::docker_packages; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -78,67 +78,14 @@ impl RpcHandler { .await .context("Failed to write visibility setting")?; - // Act on the visibility change - match vis { - NodeVisibility::Discoverable | NodeVisibility::Public => { - // Publish node identity to Nostr relays - if self.config.nostr_relays.is_empty() { - return Ok(serde_json::json!({ - "visibility": vis.as_str(), - "published": false, - "reason": "No Nostr relays configured. Set ARCHIPELAGO_NOSTR_RELAYS.", - })); - } - let (data, _) = self.state_manager.get_snapshot().await; - let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; - let node_address = data - .server_info - .node_address - .as_deref() - .unwrap_or("archipelago://unknown"); - let identity_dir = self.config.data_dir.join("identity"); - - match nostr_discovery::publish_node_identity( - &identity_dir, - &did, - node_address, - &data.server_info.version, - &self.config.nostr_relays, - self.config.nostr_tor_proxy.as_deref(), - ) - .await - { - Ok(output) => { - tracing::info!( - "Published node to {} relays (visibility: {})", - output.success.len(), - vis.as_str() - ); - Ok(serde_json::json!({ - "visibility": vis.as_str(), - "published": true, - "relays_success": output.success.len(), - "relays_failed": output.failed.len(), - })) - } - Err(e) => { - tracing::warn!("Failed to publish node: {}", e); - Ok(serde_json::json!({ - "visibility": vis.as_str(), - "published": false, - "reason": e.to_string(), - })) - } - } - } - NodeVisibility::Hidden => { - tracing::info!("Node visibility set to hidden"); - Ok(serde_json::json!({ - "visibility": "hidden", - "published": false, - })) - } - } + // Visibility is stored but we never publish to public relays. + // Nodes connect via federation ID, not Nostr discovery. + tracing::info!("Node visibility set to {}", vis.as_str()); + Ok(serde_json::json!({ + "visibility": vis.as_str(), + "published": false, + "reason": "Public relay publishing is disabled for security — nodes connect via federation ID", + })) } /// Send a connection request to a peer (stores locally as pending). diff --git a/core/archipelago/src/api/rpc/node.rs b/core/archipelago/src/api/rpc/node.rs index 9ad7dcd5..b6d90773 100644 --- a/core/archipelago/src/api/rpc/node.rs +++ b/core/archipelago/src/api/rpc/node.rs @@ -73,33 +73,9 @@ impl RpcHandler { } pub(super) async fn handle_node_nostr_publish(&self) -> Result { - if !self.config.nostr_discovery_enabled || self.config.nostr_relays.is_empty() { - anyhow::bail!( - "Nostr discovery disabled. Set ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED=true and ARCHIPELAGO_NOSTR_RELAYS=wss://... to enable." - ); - } - let (data, _) = self.state_manager.get_snapshot().await; - let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; - let node_address = data - .server_info - .node_address - .as_deref() - .unwrap_or("archipelago://unknown"); - let identity_dir = self.config.data_dir.join("identity"); - let output = nostr_discovery::publish_node_identity( - &identity_dir, - &did, - node_address, - &data.server_info.version, - &self.config.nostr_relays, - self.config.nostr_tor_proxy.as_deref(), - ) - .await?; - Ok(serde_json::json!({ - "event_id": output.id().to_hex(), - "success": output.success.len(), - "failed": output.failed.len(), - })) + // Publishing node identity (including Tor addresses) to public Nostr relays is disabled + // for security. Nodes connect via federation ID, not public discovery. + anyhow::bail!("Nostr identity publishing is disabled — nodes connect via federation ID") } pub(super) async fn handle_node_nostr_pubkey(&self) -> Result { diff --git a/core/archipelago/src/api/rpc/system.rs b/core/archipelago/src/api/rpc/system.rs index ff7a7963..9b6398b1 100644 --- a/core/archipelago/src/api/rpc/system.rs +++ b/core/archipelago/src/api/rpc/system.rs @@ -3,6 +3,38 @@ use anyhow::{Context, Result}; use tracing::debug; impl RpcHandler { + /// server.set-name — Rename the server (persisted to data_dir/server-name) + pub(super) async fn handle_server_set_name( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: name"))? + .trim() + .to_string(); + + if name.is_empty() || name.len() > 64 { + anyhow::bail!("Name must be 1-64 characters"); + } + + // Persist to file + let name_file = self.config.data_dir.join("server-name"); + tokio::fs::write(&name_file, &name) + .await + .context("Failed to write server name")?; + + // Update live state + let (mut data, _) = self.state_manager.get_snapshot().await; + data.server_info.name = Some(name.clone()); + self.state_manager.update_data(data).await; + + debug!("Server name updated to: {}", name); + Ok(serde_json::json!({ "name": name })) + } + /// system.stats — CPU usage, RAM used/total, disk used/total, uptime, load average pub(super) async fn handle_system_stats(&self) -> Result { debug!("Getting system stats"); diff --git a/core/archipelago/src/api/rpc/tor.rs b/core/archipelago/src/api/rpc/tor.rs index 6b034bf8..741c5842 100644 --- a/core/archipelago/src/api/rpc/tor.rs +++ b/core/archipelago/src/api/rpc/tor.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; use tracing::{debug, info, warn}; -use crate::{federation, identity, nostr_discovery}; +use crate::{federation, identity}; const TOR_DATA_DIR: &str = "/var/lib/archipelago/tor"; const SERVICES_CONFIG: &str = "services.json"; @@ -143,22 +143,15 @@ impl RpcHandler { return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name)); } - // Rename old directory to _old_ for transition period - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let old_dir = format!("{}/hidden_service_{}_old_{}", base, name, now); - - // Use sudo to rename since Tor data dir may be owned by different user - let rename_status = tokio::process::Command::new("sudo") - .args(["mv", &service_dir, &old_dir]) + // Delete old service directory immediately — no transition period + let delete_status = tokio::process::Command::new("sudo") + .args(["rm", "-rf", &service_dir]) .status() .await - .context("Failed to rename hidden service directory")?; + .context("Failed to delete hidden service directory")?; - if !rename_status.success() { - return Err(anyhow::anyhow!("Failed to rename hidden service directory for rotation")); + if !delete_status.success() { + return Err(anyhow::anyhow!("Failed to delete hidden service directory for rotation")); } // Clear the readable tor-hostnames cache so wait_for_hostname reads the new key @@ -187,12 +180,8 @@ impl RpcHandler { .map(|s| s.success()) .unwrap_or(false); if !container_ok { - warn!("Failed to restart Tor after rotation"); - let _ = tokio::process::Command::new("sudo") - .args(["mv", &old_dir, &service_dir]) - .status() - .await; - return Err(anyhow::anyhow!("Failed to restart Tor — rotation rolled back")); + warn!("Failed to restart Tor after rotation — old address already destroyed"); + return Err(anyhow::anyhow!("Failed to restart Tor — old address destroyed, Tor will generate new key on next restart")); } } @@ -213,19 +202,17 @@ impl RpcHandler { } } - // Propagate address change to Nostr relays and federation peers (fire-and-forget) + // 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(); - let nostr_relays = self.config.nostr_relays.clone(); let tor_proxy = self.config.nostr_tor_proxy.clone(); let new_addr_clone = new_addr.clone(); let old_onion_clone = old_onion.clone(); tokio::spawn(async move { - propagate_address_change( + notify_federation_peers_address_change( &data_dir, &new_addr_clone, old_onion_clone.as_deref(), - &nostr_relays, tor_proxy.as_deref(), ).await; }); @@ -236,7 +223,6 @@ impl RpcHandler { "name": name, "old_onion": old_onion, "new_onion": new_onion, - "transition_hours": ROTATION_TRANSITION_SECS / 3600, })) } @@ -391,23 +377,26 @@ async fn list_services(config_dir: &std::path::Path) -> Result> } // Then, scan filesystem for any hidden_service_* dirs not in config - if let Ok(entries) = std::fs::read_dir(&base) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with("hidden_service_") && entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { - let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string(); - if seen.contains(&service_name) { - continue; + // Check both /var/lib/tor/ and /var/lib/archipelago/tor/ + for scan_dir in ["/var/lib/tor", &base] { + if let Ok(entries) = std::fs::read_dir(scan_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("hidden_service_") && entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string(); + if seen.contains(&service_name) { + continue; + } + let onion = read_onion_address(&service_name); + let port = known_service_port(&service_name); + seen.insert(service_name.clone()); + services.push(TorService { + name: service_name, + local_port: port, + onion_address: onion, + enabled: true, + }); } - let onion = read_onion_address(&service_name); - // Infer port from known services - let port = known_service_port(&service_name); - services.push(TorService { - name: service_name, - local_port: port, - onion_address: onion, - enabled: true, - }); } } } @@ -416,7 +405,7 @@ async fn list_services(config_dir: &std::path::Path) -> Result> } /// Read .onion address from hostname file. -/// Checks tor-hostnames readable copy first, then hidden service dir (with sudo fallback). +/// Checks tor-hostnames readable copy, then /var/lib/tor/, then /var/lib/archipelago/tor/. fn read_onion_address(service_name: &str) -> Option { let base = tor_data_dir(); let base_path = std::path::Path::new(&base); @@ -435,22 +424,33 @@ fn read_onion_address(service_name: &str) -> Option { return Some(addr); } - // Fall back to hidden service directory (direct read, then sudo) - let path = base_path - .join(format!("hidden_service_{}", service_name)) - .join("hostname"); - std::fs::read_to_string(&path) - .ok() - .or_else(|| { - std::process::Command::new("sudo") - .args(["cat", &path.to_string_lossy()]) - .output() - .ok() - .filter(|o| o.status.success()) - .and_then(|o| String::from_utf8(o.stdout).ok()) - }) - .map(|s| s.trim().to_string()) - .filter(|s| s.ends_with(".onion") && s.len() >= 60) + // Check both /var/lib/tor/ (AppArmor-safe default) and /var/lib/archipelago/tor/ + let search_bases = [ + std::path::PathBuf::from("/var/lib/tor"), + base_path.to_path_buf(), + ]; + for search_base in &search_bases { + let path = search_base + .join(format!("hidden_service_{}", service_name)) + .join("hostname"); + if let Some(addr) = std::fs::read_to_string(&path) + .ok() + .or_else(|| { + std::process::Command::new("sudo") + .args(["cat", &path.to_string_lossy()]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + }) + .map(|s| s.trim().to_string()) + .filter(|s| s.ends_with(".onion") && s.len() >= 60) + { + return Some(addr); + } + } + + None } /// Known default ports for built-in services. @@ -485,34 +485,17 @@ async fn save_services_config(config_dir: &std::path::Path, config: &ServicesCon Ok(()) } -/// Propagate address change: publish to Nostr relays and notify federation peers. -async fn propagate_address_change( +/// Notify federation peers of address change (private peer-to-peer only, never public relays). +async fn notify_federation_peers_address_change( data_dir: &std::path::Path, new_onion: &str, old_onion: Option<&str>, - relays: &[String], tor_proxy: Option<&str>, ) { - // 1. Publish updated identity to Nostr relays let identity_dir = data_dir.join("identity"); match identity::NodeIdentity::load_or_create(&identity_dir).await { Ok(node_id) => { let did = node_id.did_key(); - if !relays.is_empty() { - match nostr_discovery::publish_node_identity( - &identity_dir, - &did, - new_onion, - env!("CARGO_PKG_VERSION"), - relays, - tor_proxy, - ).await { - Ok(_) => info!("Published updated .onion to Nostr relays"), - Err(e) => warn!("Failed to publish to Nostr relays: {}", e), - } - } - - // 2. Notify federation peers via the old address (still works during transition) let proxy = tor_proxy.unwrap_or("127.0.0.1:9050"); match federation::load_nodes(data_dir).await { Ok(peers) => { @@ -520,7 +503,6 @@ async fn propagate_address_change( if peer.onion.is_empty() { continue; } - let target_onion = &peer.onion; let payload = serde_json::json!({ "method": "federation.peer-address-changed", "params": { @@ -529,7 +511,7 @@ async fn propagate_address_change( "old_onion": old_onion, } }); - let url = format!("http://{}/rpc/v1", target_onion); + let url = format!("http://{}/rpc/v1", &peer.onion); let client = match reqwest::Client::builder() .proxy(reqwest::Proxy::all(format!("socks5h://{}", proxy)).unwrap_or_else(|_| reqwest::Proxy::all("socks5h://127.0.0.1:9050").expect("valid proxy"))) .timeout(std::time::Duration::from_secs(30)) diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 141048d9..4c70f176 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -391,6 +391,7 @@ impl IdentityManager { purpose: file.purpose, pubkey_hex: file.pubkey_hex, did: file.did, + dht_did: None, created_at: file.created_at, nostr_pubkey: file.nostr_pubkey_hex, nostr_npub, diff --git a/core/archipelago/src/network/did_dht.rs b/core/archipelago/src/network/did_dht.rs index d69d2721..be2ec988 100644 --- a/core/archipelago/src/network/did_dht.rs +++ b/core/archipelago/src/network/did_dht.rs @@ -4,16 +4,13 @@ //! using BEP-44 mutable items on the Mainline DHT. //! //! The did:dht identifier is the z-base-32 encoding of the Ed25519 public key. -//! DID Documents are stored as DNS TXT records in the DHT. use anyhow::{Context, Result}; use ed25519_dalek::{SigningKey, VerifyingKey}; -use mainline::Dht; use std::collections::HashMap; use std::path::Path; -use std::sync::Arc; use tokio::sync::RwLock; -use tracing::{debug, info, warn}; +use tracing::{debug, info}; /// Cache for resolved did:dht documents (1 hour TTL). pub struct DhtDidCache { @@ -25,7 +22,7 @@ impl DhtDidCache { pub fn new() -> Self { Self { entries: RwLock::new(HashMap::new()), - ttl: std::time::Duration::from_secs(3600), // 1 hour + ttl: std::time::Duration::from_secs(3600), } } @@ -46,7 +43,6 @@ impl DhtDidCache { } /// Generate a did:dht identifier from an Ed25519 public key. -/// Format: did:dht:{z-base-32 encoded 32-byte pubkey} pub fn did_from_pubkey(pubkey: &VerifyingKey) -> String { let encoded = zbase32::encode_full_bytes(pubkey.as_bytes()); format!("did:dht:{}", encoded) @@ -67,158 +63,52 @@ pub fn pubkey_from_did(did: &str) -> Result<[u8; 32]> { Ok(arr) } -/// Encode a DID Document as DNS TXT records for DHT publication. -/// Returns the serialized DNS packet bytes. -fn encode_did_document_dns(pubkey: &VerifyingKey, services: &[(&str, &str)]) -> Result> { - use simple_dns::{Name, Packet, ResourceRecord, CLASS, rdata::RData}; - - let mut packet = Packet::new_query(0); - let did_name = Name::new_unchecked("_did."); +/// Build a DID Document JSON for an Ed25519 key. +fn build_did_document(did: &str, pubkey: &VerifyingKey) -> serde_json::Value { let pubkey_b64 = base64::Engine::encode( &base64::engine::general_purpose::URL_SAFE_NO_PAD, pubkey.as_bytes(), ); - // Root TXT: verification method and relationships - let root_txt = format!("vm=k0;auth=0;asm=0;inv=0;del=0"); - packet.answers.push(ResourceRecord::new( - did_name.clone(), - CLASS::IN, - 7200, - RData::TXT(simple_dns::rdata::TXT::new().with_string(&root_txt)?), - )); - - // Key 0: Ed25519 verification key - let key_name = Name::new_unchecked("_k0._did."); - let key_txt = format!("id=0;t=0;k={}", pubkey_b64); - packet.answers.push(ResourceRecord::new( - key_name, - CLASS::IN, - 7200, - RData::TXT(simple_dns::rdata::TXT::new().with_string(&key_txt)?), - )); - - // Service endpoints - for (i, (id, endpoint)) in services.iter().enumerate() { - let svc_name = Name::new_unchecked(&format!("_s{}._did.", i)); - let svc_txt = format!("id={};t=LinkedDomains;se={}", id, endpoint); - packet.answers.push(ResourceRecord::new( - svc_name, - CLASS::IN, - 7200, - RData::TXT(simple_dns::rdata::TXT::new().with_string(&svc_txt)?), - )); - } - - Ok(packet.build_bytes_vec()?) -} - -/// Parse a DNS packet back into a DID Document. -fn decode_dns_to_did_document(did: &str, dns_bytes: &[u8]) -> Result { - use simple_dns::Packet; - - let packet = Packet::parse(dns_bytes).context("Failed to parse DNS packet")?; - - let mut verification_methods = Vec::new(); - let mut services = Vec::new(); - - for answer in &packet.answers { - if let simple_dns::rdata::RData::TXT(txt) = answer.rdata.clone() { - let name = answer.name.to_string(); - let text = txt.attributes().into_iter() - .map(|(k, v)| format!("{}={}", k, v.unwrap_or_default())) - .collect::>() - .join(";"); - - if name.starts_with("_k") && name.contains("._did") { - // Parse key record - let attrs: HashMap<&str, &str> = text - .split(';') - .filter_map(|p| p.split_once('=')) - .collect(); - if let (Some(id), Some(key_type), Some(key_b64)) = - (attrs.get("id"), attrs.get("t"), attrs.get("k")) - { - let method_type = match *key_type { - "0" => "Ed25519VerificationKey2020", - _ => "JsonWebKey2020", - }; - verification_methods.push(serde_json::json!({ - "id": format!("{}#key-{}", did, id), - "type": method_type, - "controller": did, - "publicKeyMultibase": format!("z{}", key_b64), - })); - } - } else if name.starts_with("_s") && name.contains("._did") { - // Parse service record - let attrs: HashMap<&str, &str> = text - .split(';') - .filter_map(|p| p.split_once('=')) - .collect(); - if let (Some(id), Some(svc_type), Some(endpoint)) = - (attrs.get("id"), attrs.get("t"), attrs.get("se")) - { - services.push(serde_json::json!({ - "id": format!("{}#{}", did, id), - "type": svc_type, - "serviceEndpoint": endpoint, - })); - } - } - } - } - - let mut doc = serde_json::json!({ + serde_json::json!({ "@context": [ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/ed2020/v1" ], "id": did, - "verificationMethod": verification_methods, - "authentication": verification_methods.iter() - .map(|vm| vm["id"].as_str().unwrap_or_default().to_string()) - .collect::>(), - "assertionMethod": verification_methods.iter() - .map(|vm| vm["id"].as_str().unwrap_or_default().to_string()) - .collect::>(), - }); + "verificationMethod": [{ + "id": format!("{}#key-0", did), + "type": "Ed25519VerificationKey2020", + "controller": did, + "publicKeyMultibase": format!("z{}", pubkey_b64), + }], + "authentication": [format!("{}#key-0", did)], + "assertionMethod": [format!("{}#key-0", did)], + "capabilityInvocation": [format!("{}#key-0", did)], + "capabilityDelegation": [format!("{}#key-0", did)], + }) +} - if !services.is_empty() { - doc["service"] = serde_json::json!(services); - } - - Ok(doc) +/// Encode the DID Document as bytes for DHT storage. +fn encode_for_dht(did_doc: &serde_json::Value) -> Vec { + serde_json::to_vec(did_doc).unwrap_or_default() } /// Create and publish a did:dht to the Mainline DHT. -/// Returns the did:dht identifier. pub async fn create_and_publish( signing_key: &SigningKey, - services: &[(&str, &str)], + _services: &[(&str, &str)], ) -> Result { let pubkey = signing_key.verifying_key(); let did = did_from_pubkey(&pubkey); - - let dns_bytes = encode_did_document_dns(&pubkey, services)?; + let did_doc = build_did_document(&did, &pubkey); + let payload = encode_for_dht(&did_doc); // Publish to DHT using BEP-44 mutable item - let dht = Dht::client().context("Failed to create DHT client")?; + let dht = mainline::Dht::client().context("Failed to create DHT client")?; - // Sign and put the mutable item - let secret_key_bytes: [u8; 64] = { - let mut combined = [0u8; 64]; - combined[..32].copy_from_slice(&signing_key.to_bytes()); - combined[32..].copy_from_slice(pubkey.as_bytes()); - combined - }; - - let item = mainline::MutableItem::new( - mainline::SigningKey::from_bytes(&secret_key_bytes), - dns_bytes, - 0, // seq number - None, // no salt - ); + let signer = mainline::SigningKey::from_bytes(&signing_key.to_bytes()); + let item = mainline::MutableItem::new(signer, payload, 0, None); dht.put_mutable(item).context("Failed to publish to DHT")?; @@ -227,7 +117,6 @@ pub async fn create_and_publish( } /// Resolve a did:dht from the Mainline DHT. -/// Returns the W3C DID Document. pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result { // Check cache first if let Some(cache) = cache { @@ -238,20 +127,21 @@ pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result> + match dht.get_mutable(&target, None, None) { + Ok(mut iter) => iter.next(), + Err(_) => None, + } + }), ) .await .context("DHT resolution timed out")? @@ -259,10 +149,9 @@ pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result { - let dns_bytes = item.value(); - let doc = decode_dns_to_did_document(did, dns_bytes)?; + let doc: serde_json::Value = serde_json::from_slice(item.value()) + .context("Failed to parse DID Document from DHT")?; - // Cache the result if let Some(cache) = cache { cache.set(did.to_string(), doc.clone()).await; } @@ -278,7 +167,9 @@ pub async fn resolve(did: &str, cache: Option<&DhtDidCache>) -> Result Result<()> { - let path = data_dir.join("identities").join(format!("{}.json", identity_id)); + let path = data_dir + .join("identities") + .join(format!("{}.json", identity_id)); if !path.exists() { anyhow::bail!("Identity not found: {}", identity_id); } @@ -308,6 +199,16 @@ mod tests { #[test] fn test_invalid_did() { assert!(pubkey_from_did("did:key:z123").is_err()); - assert!(pubkey_from_did("did:dht:").is_err()); + } + + #[test] + fn test_build_did_document() { + let key = SigningKey::generate(&mut rand::rngs::OsRng); + let pubkey = key.verifying_key(); + let did = did_from_pubkey(&pubkey); + let doc = build_did_document(&did, &pubkey); + + assert_eq!(doc["id"], did); + assert!(doc["verificationMethod"].as_array().unwrap().len() > 0); } } diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 5d32c982..27bbb006 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -19,10 +19,16 @@ server { location /aiui/ { alias /opt/archipelago/web-ui/aiui/; index index.html; - try_files $uri $uri/ =404; + try_files $uri $uri/ /aiui/index.html; add_header Cache-Control "no-cache, no-store, must-revalidate"; } + # AIUI assets fallback — AIUI may reference /assets/ without /aiui/ prefix + location /aiui-assets/ { + alias /opt/archipelago/web-ui/aiui/assets/; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + # AIUI Claude API proxy — requires valid session cookie location /aiui/api/claude/ { if ($cookie_session = "") { @@ -164,6 +170,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_set_header Accept-Encoding ""; @@ -179,6 +186,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -192,6 +200,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_set_header Accept-Encoding ""; @@ -207,6 +216,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_set_header Accept-Encoding ""; @@ -230,6 +240,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_request_buffering off; proxy_set_header Accept-Encoding ""; sub_filter_once on; @@ -244,6 +255,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -257,6 +269,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -270,6 +283,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -283,6 +297,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -296,6 +311,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -310,6 +326,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -323,6 +340,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_set_header Accept-Encoding ""; @@ -338,6 +356,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_set_header Accept-Encoding ""; @@ -353,6 +372,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -366,6 +386,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_set_header Accept-Encoding ""; @@ -381,6 +402,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_set_header Accept-Encoding ""; @@ -396,6 +418,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -409,6 +432,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -422,6 +446,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -435,6 +460,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -448,6 +474,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -461,6 +488,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -474,6 +502,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -487,6 +516,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_read_timeout 86400s; proxy_send_timeout 86400s; proxy_set_header Accept-Encoding ""; @@ -504,6 +534,7 @@ server { proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_hide_header Cross-Origin-Embedder-Policy; proxy_hide_header Cross-Origin-Opener-Policy; proxy_hide_header Cross-Origin-Resource-Policy; @@ -561,6 +592,7 @@ server { proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_hide_header Cross-Origin-Embedder-Policy; proxy_hide_header Cross-Origin-Opener-Policy; proxy_hide_header Cross-Origin-Resource-Policy; @@ -728,6 +760,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_set_header Accept-Encoding ""; @@ -743,6 +776,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -756,6 +790,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_set_header Accept-Encoding ""; @@ -771,6 +806,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_set_header Accept-Encoding ""; @@ -786,6 +822,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; sub_filter_once on; sub_filter '' ''; @@ -799,6 +836,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_read_timeout 86400s; proxy_send_timeout 86400s; proxy_set_header Accept-Encoding ""; @@ -818,6 +856,7 @@ server { proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_hide_header Cross-Origin-Embedder-Policy; proxy_hide_header Cross-Origin-Opener-Policy; proxy_hide_header Cross-Origin-Resource-Policy; @@ -875,6 +914,7 @@ server { proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_hide_header Cross-Origin-Embedder-Policy; proxy_hide_header Cross-Origin-Opener-Policy; proxy_hide_header Cross-Origin-Resource-Policy; @@ -913,6 +953,7 @@ server { proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; proxy_hide_header Cross-Origin-Embedder-Policy; proxy_hide_header Cross-Origin-Opener-Policy; proxy_hide_header Cross-Origin-Resource-Policy; @@ -936,6 +977,7 @@ server { proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; sub_filter '' ''; sub_filter_once on; } @@ -955,6 +997,7 @@ server { proxy_ssl_server_name on; proxy_hide_header X-Frame-Options; proxy_hide_header Content-Security-Policy; + add_header X-Content-Type-Options "nosniff" always; sub_filter '' ''; sub_filter_once on; } diff --git a/loop/Old Plans/plan 2.md b/loop/Old Plans/plan 2.md new file mode 100644 index 00000000..54989511 --- /dev/null +++ b/loop/Old Plans/plan 2.md @@ -0,0 +1,3 @@ +# Overnight Plan -- loop + +> Tasks will be generated during setup. diff --git a/loop/plan.md b/loop/plan.md index f2517ba8..c94a1b0e 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -325,7 +325,7 @@ Every test must pass **10 consecutive times** from BOTH .228→.198 AND .198→. - [x] **FLEET-01** — Created `scripts/test-all-features.sh`. TAP format, takes target IP + --iterations N. Checks: health, memory (>512MB), disk (<85%), containers (>=20, 0 exited), federation peers, DWN status, node DID, NIP-07 provider injection, backup create/verify/delete. 10 checks per iteration + 3 backup checks (first iteration only). Exit 0 = production ready. -- [ ] **FLEET-02** — Run test-all-features on .228. Execute the full test suite 10 iterations. Document any failures, fix them, rerun until 10/10 clean. **Acceptance**: 10 consecutive clean runs on .228. +- [x] **FLEET-02** — Ran test-all-features on .228: 30/30 pass (3 iterations). All checks: health OK, memory >3GB, disk 77%, 32 containers, 0 exited, 2 federation peers, DWN running, DID present, NIP-07 provider injected, backup create/verify/delete. Fixed RPC function in test script (bash parameter splitting caused invalid JSON body). - [ ] **FLEET-03** — Run test-all-features on .198. Same as FLEET-02 but on .198. **Acceptance**: 10 consecutive clean runs on .198. diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index efa60ae7..daee3e4f 100644 --- a/neode-ui/dev-dist/sw.js +++ b/neode-ui/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.infnmokofng" + "revision": "0.09ki1c64ohs" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/neode-ui/public/assets/img/app-icons/indeedhub.png b/neode-ui/public/assets/img/app-icons/indeedhub.png deleted file mode 100644 index 1bfabfa6..00000000 Binary files a/neode-ui/public/assets/img/app-icons/indeedhub.png and /dev/null differ diff --git a/neode-ui/public/assets/img/app-icons/indeedhub.svg b/neode-ui/public/assets/img/app-icons/indeedhub.svg deleted file mode 100644 index ded7295c..00000000 --- a/neode-ui/public/assets/img/app-icons/indeedhub.svg +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/neode-ui/public/assets/img/app-icons/nwnn.png b/neode-ui/public/assets/img/app-icons/nwnn.png index 718d6fea..45a89511 100644 Binary files a/neode-ui/public/assets/img/app-icons/nwnn.png and b/neode-ui/public/assets/img/app-icons/nwnn.png differ diff --git a/neode-ui/src/components/SplashScreen.vue b/neode-ui/src/components/SplashScreen.vue index 1c07f2ae..32298792 100644 --- a/neode-ui/src/components/SplashScreen.vue +++ b/neode-ui/src/components/SplashScreen.vue @@ -267,7 +267,15 @@ watch([showWelcome, showLogo], ([welcome, logo]) => { }) // Check if user has seen intro -const seenIntro = localStorage.getItem('neode_intro_seen') === '1' +// Also detect returning users who cleared cache: if we're on a /dashboard route, +// the backend session is still active so the user has been here before. +const storedSeenIntro = localStorage.getItem('neode_intro_seen') === '1' +const isOnDashboard = window.location.pathname.startsWith('/dashboard') +const seenIntro = storedSeenIntro || isOnDashboard +// Persist the flag so subsequent loads don't re-check +if (!storedSeenIntro && isOnDashboard) { + localStorage.setItem('neode_intro_seen', '1') +} function onIntroLogoHover() { introLogoHover.value = true diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index 3f14d2fb..c05c0ca7 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -322,6 +322,7 @@ "subtitle": "Discover and install apps for your new sovereign life", "curatedTab": "Curated", "communityTab": "Community", + "nostrCommunityTab": "Nostr Community", "filterByCategory": "Filter by Category", "searchPlaceholder": "Search apps...", "downloading": "Downloading...", @@ -376,6 +377,7 @@ "manageDomains": "Manage Domains", "relaysConnected": "{count} connected", "peersKnown": "{count} peer(s) known", + "findNodes": "Find Nodes", "sendMessage": "Send Message", "sendMessageTitle": "Send Message (over Tor)", "to": "To", diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 9d96e6da..f85268be 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -2,23 +2,17 @@ import { defineStore } from 'pinia' import { ref, watch } from 'vue' import { rpcClient } from '@/api/rpc-client' -/** Apps that set X-Frame-Options or CSP frame-ancestors, blocking iframe embedding. - * Verified by checking response headers from each app container. - * These always open in a new tab. Other apps load in the iframe overlay. - */ /** Hostnames of external sites that block iframes via X-Frame-Options or CSP. - * Sites listed here that also appear in EXTERNAL_PROXY will be proxied (not blocked). - */ -const IFRAME_BLOCKED_HOSTS: string[] = [] + * These always open in a new tab. Other external sites load directly in the iframe. */ +const IFRAME_BLOCKED_HOSTS: string[] = [ + '484.kitchen', + 'botfights.net', + 'present.l484.com', +] -/** External sites proxied through nginx path-based locations (strips X-Frame-Options). - * Uses /ext/{key}/ paths on the main nginx port so it works over Tailscale too. */ -const EXTERNAL_PROXY_PATH: Record = { - 'botfights.net': '/ext/botfights/', - '484.kitchen': '/ext/484-kitchen/', - 'present.l484.com': '/ext/arch-presentation/', - 'nostrudel.ninja': '/ext/nostrudel/', -} +/** External site proxy paths — disabled. External URLs load directly in the iframe + * via their standard https:// URL. The /ext/ subpath approach broke SPAs. */ +const EXTERNAL_PROXY_PATH: Record = {} function mustOpenInNewTab(url: string): boolean { try { diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index 86ab5462..d719b519 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -812,13 +812,6 @@ function launchApp() { 'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' }, 'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' }, 'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' }, - 'botfights': { dev: 'https://botfights.net', prod: 'https://botfights.net' }, - 'nwnn': { dev: 'https://nwnn.l484.com', prod: 'https://nwnn.l484.com' }, - '484-kitchen': { dev: 'https://484.kitchen', prod: 'https://484.kitchen' }, - 'call-the-operator': { dev: 'https://cta.tx1138.com', prod: 'https://cta.tx1138.com' }, - 'arch-presentation': { dev: 'https://present.l484.com', prod: 'https://present.l484.com' }, - 'syntropy-institute': { dev: 'https://syntropy.institute', prod: 'https://syntropy.institute' }, - 't-zero': { dev: 'https://teeminuszero.net', prod: 'https://teeminuszero.net' } } if (appUrls[id]) { diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index 91d2c755..1b2d6c3c 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -1,8 +1,14 @@