/* This file is part of DarkFi (https://dark.fi) * * Copyright (C) 2020-2026 Dyne.org foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ use std::{ collections::{HashMap, HashSet}, io::Cursor, sync::{atomic::Ordering, Arc, OnceLock, Weak}, time::UNIX_EPOCH, }; use async_lock::RwLock; use crypto_box::{ChaChaBox, PublicKey, SecretKey}; use darkfi::{ event_graph::{ self, proto::{EventPut, ProtocolEventGraph}, EventGraph, EventGraphConfig, EventGraphPtr, }, net::{ dnet::DnetEvent, session::SESSION_DEFAULT, settings::{MagicBytes, NetworkProfile, Settings as NetSettings}, ChannelPtr, P2p, P2pPtr, }, system::{sleep, Subscription}, Result as DarkFiResult, }; use darkfi_serial::{ deserialize_async, serialize, serialize_async, AsyncEncodable, Decodable, Encodable, }; use irc2::{ crypto::saltbox, irc::{server::MAX_NICK_LEN, IrcChannel, IrcContact}, pad, unpad, Privmsg, }; use kvdb_overlay::Database as KvDb; use parking_lot::Mutex as SyncMutex; use crate::{ app::schema::{ ensure_joined_channels_seeded, menu::{channel::Channel, contact::Contact}, read_joined_channels, }, db::AppDbPtr, error::{Error, Result}, prop::{ BatchGuardPtr, PropertyAtomicGuard, PropertyBool, PropertyEnum, PropertyPtr, PropertyStr, Role, }, scene::{MethodCallSub, Pimpl, SceneNodePtr, SceneNodeWeak, Slot}, ui::{ chatview::{MessageId, Timestamp}, OnModify, }, ExecutorPtr, }; const P2P_RETRY_TIME: u64 = 20; const COOLOFF_SLEEP_TIME: u64 = 20; const COOLOFF_SYNC_ATTEMPTS: usize = 6; const SYNC_MIN_PEERS: usize = 2; /// Event graph rotation: `max_dags` hourly slots (`hours_rotation: 1`). const DAGS_COUNT: u64 = 24; /// Milliseconds in one rotation period (1 hour). const HOUR_MS: u64 = 3_600_000; pub(crate) const P2P_OUTBOUND_ACTIVE: usize = 3; const P2P_OUTBOUND_SLEEP: usize = 1; /// Update `outbound_peers` property useful for diagnostics const DNET_ENABLED: bool = true; /// Due to drift between different machine's clocks, if the message timestamp is recent /// then we will just correct it to the current time so messages appear sequential in the UI. const RECENT_TIME_DIST: u64 = 25_000; // NOTE: if `paths` already lives in a shared module (e.g. `super::paths` from // darkirc.rs's parent module), delete this block and add `use super::paths::*;` // instead. Duplicated here so this file compiles standalone. #[cfg(target_os = "android")] mod paths { use crate::android::{get_appdata_path, get_external_storage_path}; use std::path::PathBuf; pub fn get_chatdb_path() -> PathBuf { get_external_storage_path().join("chatdb") } pub fn p2p_datastore_path() -> PathBuf { get_appdata_path().join("darkirc2_p2p") } pub fn hostlist_path() -> PathBuf { get_appdata_path().join("hostlist2.tsv") } } #[cfg(not(target_os = "android"))] mod paths { use std::path::PathBuf; pub fn get_chatdb_path() -> PathBuf { dirs::data_local_dir().unwrap().join("darkfi/app/chatdb") } pub fn p2p_datastore_path() -> PathBuf { dirs::cache_dir().unwrap().join("darkfi/app/darkirc2_p2p") } pub fn hostlist_path() -> PathBuf { dirs::cache_dir().unwrap().join("darkfi/app/hostlist2.tsv") } } use paths::*; macro_rules! t { ($($arg:tt)*) => { trace!(target: "plugin::darkirc2", $($arg)*); } } macro_rules! d { ($($arg:tt)*) => { debug!(target: "plugin::darkirc2", $($arg)*); } } macro_rules! i { ($($arg:tt)*) => { info!(target: "plugin::darkirc2", $($arg)*); } } macro_rules! e { ($($arg:tt)*) => { error!(target: "plugin::darkirc2", $($arg)*); } } macro_rules! w { ($($arg:tt)*) => { warn!(target: "plugin::darkirc2", $($arg)*); } } struct SeenMsg { id: MessageId, is_self: bool, seen_times: usize, } struct SeenMessages { seen: Vec, } impl SeenMessages { fn new() -> Self { Self { seen: vec![] } } fn get_status(&self, id: &MessageId) -> Option<&SeenMsg> { self.seen.iter().find(|s| s.id == *id) } fn push(&mut self, id: MessageId, is_self: bool) { self.seen.push(SeenMsg { id, is_self, seen_times: 0 }); } } pub type DarkIrcPtr = Arc; pub struct DarkIrc { node: SceneNodeWeak, tasks: SyncMutex>>, p2p: P2pPtr, event_graph: EventGraphPtr, seen_msgs: SyncMutex, nick: PropertyStr, chat_is_enabled: PropertyBool, net_transport: PropertyEnum, pub channels: RwLock>, pub contacts: RwLock>, app_db: AppDbPtr, dm_secret: SecretKey, ex: ExecutorPtr, } impl DarkIrc { pub async fn new( node: SceneNodeWeak, sg_root: SceneNodePtr, ex: ExecutorPtr, kv_db: KvDb, app_db: AppDbPtr, ) -> Result { let node_ref = &node.upgrade().unwrap(); let nick = PropertyStr::wrap(node_ref, Role::Internal, "nick", 0).unwrap(); let setting_node = sg_root.lookup_node("/setting").unwrap(); let chat_is_enabled = PropertyBool::wrap(&setting_node, Role::User, "chat.is_enabled", 0).unwrap(); let net_transport = PropertyEnum::wrap(&setting_node, Role::Internal, "net.transport", 0).unwrap(); i!("Starting DarkIRC backend"); let dm_secret_bytes = app_db.dm_secret().await?; let dm_secret = SecretKey::from_bytes(dm_secret_bytes); let dm_public_b58 = bs58::encode(dm_secret.public_key().to_bytes()).into_string(); // Expose our DM public key on the plugin node so it can be displayed/shared. node_ref .set_property_str( &mut PropertyAtomicGuard::none(), Role::Internal, "dm_public", &dm_public_b58, ) .unwrap(); i!("DM identity public key (share with contacts): {dm_public_b58}"); let mut p2p_settings: NetSettings = Default::default(); p2p_settings.magic_bytes = MagicBytes([251, 229, 199, 181]); p2p_settings.app_version = semver::Version::parse("0.5.0").unwrap(); p2p_settings.app_name = "darkirc".to_string(); p2p_settings.inbound_connections = 2; let transport = net_transport.get(); Self::apply_transport_settings(&mut p2p_settings, &transport); p2p_settings.p2p_datastore = p2p_datastore_path().into_os_string().into_string().ok(); p2p_settings.hostlist = hostlist_path().into_os_string().into_string().ok(); let p2p = match P2p::new(p2p_settings.clone(), ex.clone()).await { Ok(p2p) => p2p, Err(err) => { e!("Create p2p network failed: {err}!"); return Err(Error::ServiceFailed) } }; if DNET_ENABLED { i!("Enabling dnet outbound-slot event stream for outbound_peers property"); p2p.dnet_enable(); } let event_graph = match EventGraph::new( p2p.clone(), kv_db.clone(), std::path::PathBuf::new(), false, EventGraphConfig { initial_genesis: 1_704_067_200_000, hours_rotation: 1, genesis_contents: b"darkirc-v1".to_vec(), rln_enabled: false, pregenerated_identity_commitments: vec![], max_dags: Some(24), }, ex.clone(), ) .await { Ok(evgr) => evgr, Err(err) => { e!("Create event graph failed: {err}!"); return Err(Error::ServiceFailed) } }; if let Some(prev_nick) = app_db.nick_get().await? { nick.set(&mut PropertyAtomicGuard::none(), prev_nick); } let self_ = Arc::new(Self { node: node.clone(), tasks: SyncMutex::new(vec![]), p2p, event_graph, seen_msgs: SyncMutex::new(SeenMessages::new()), nick, chat_is_enabled, net_transport, channels: RwLock::new(HashMap::new()), contacts: RwLock::new(HashMap::new()), app_db, dm_secret, ex: ex.clone(), }); self_.p2p.settings().write().await.outbound_connections = P2P_OUTBOUND_ACTIVE; ensure_joined_channels_seeded(); self_.load_channels_from_db().await; self_.load_contacts_from_db().await; self_.clone().start(sg_root, ex).await; Ok(Pimpl::DarkIrc(self_)) } async fn dag_sync(self: Arc, channel_sub: Subscription>) { i!("Waiting for some P2P connections..."); let mut sync_attempt = 0; // TODO: these should be configurable let fast_mode = false; let mut newest_synced = false; loop { let peers_count = self.p2p.peers_count(); self.notify_connect(peers_count, self.event_graph.is_synced()).await; // Wait until we have enough connections if peers_count < SYNC_MIN_PEERS { i!("Connected to {peers_count} peers. Waiting for more connections."); let conn_sub = self.p2p.hosts().subscribe_channel().await; loop { if let Err(err) = conn_sub.receive().await { w!("Error while waiting for new connections: {err}"); continue } if self.p2p.peers_count() >= SYNC_MIN_PEERS { break } } } i!("Got peer connection"); sync_attempt += 1; // Cool off periodically if sync_attempt > COOLOFF_SYNC_ATTEMPTS { i!("Wasn't able to sync yet. Cooling off for {COOLOFF_SLEEP_TIME} then will try again."); sleep(COOLOFF_SLEEP_TIME).await; sync_attempt = 0; } i!("Syncing static DAG"); match self.event_graph.static_sync().await { Ok(()) => { i!("Static synced successfully"); // log_memory("after static sync"); } Err(e) => { e!("Failed syncing static graph: {e}"); self.p2p.stop().await; break } } // Sync only the newest DAG first. Older DAGs are caught up in // the background by `catch_up_sync`, so the `synced` flag (and // the `connect` notification) is reached after the first DAG // instead of after all `DAGS_COUNT` of them. let latest_ts = self.event_graph.current_genesis.read().await.header.timestamp; i!("Syncing newest event DAG ({latest_ts}) (attempt #{sync_attempt})"); let sync_result = self.sync_dag_slot(latest_ts, fast_mode).await; match sync_result { Ok(()) => { i!( "Newest event DAG synced successfully ({} mode)", if fast_mode { "fast" } else { "full" }, ); newest_synced = true; break } Err(e) => { // TODO: Maybe at this point we should prune or something? // TODO: Or maybe just tell the user to delete the DAG from FS. e!("Failed syncing newest DAG ({e}), retrying..."); } } } // The newest DAG is synced: flip the `synced` flag so live `EventPut` // ingestion and the netstatus UI unblock, then let the remaining older // DAGs catch up in the background. if newest_synced { self.event_graph.synced.store(true, Ordering::Release); i!("Marked event graph as synced (newest DAG done)"); let self_ = self.clone(); let catch_up_task = self.ex.clone().spawn(async move { self_.catch_up_sync(fast_mode).await }); self.tasks.lock().push(catch_up_task); } let peers_count = self.p2p.peers_count(); self.notify_connect(peers_count, self.event_graph.is_synced()).await; // Initial sync finished. Now just notify of connection changes loop { // Wait for a channel if let Err(err) = channel_sub.receive().await { w!("There was an error listening for channels. The service closed unexpectedly with error: {err}"); continue } let peers_count = self.p2p.peers_count(); self.notify_connect(peers_count, self.event_graph.is_synced()).await; } } /// Sync a single DAG slot, choosing `dag_sync_headers` (fast) or `dag_sync` /// (full) per the per-call `fast_mode` flag. Shared by the foreground /// newest-DAG phase and the background catch-up phase. async fn sync_dag_slot(&self, dag_ts: u64, fast_mode: bool) -> DarkFiResult<()> { if fast_mode { self.event_graph.dag_sync_headers(dag_ts).await } else { self.event_graph.dag_sync(dag_ts).await } } /// Background catch-up phase: after the newest DAG has synced (phase 1), /// walk the remaining DAG slots newest-to-oldest and sync each one. /// /// Per-slot failures are retried across rounds with `COOLOFF_SLEEP_TIME` /// pauses (no give-up), while the walk keeps making progress on the other /// pending slots. `current_genesis` is re-read before every retry round so /// rotation mid-catch-up is handled: a newer slot that rotates in is synced /// first, and a pending slot that rotates out of the retention window is /// dropped from the walk. async fn catch_up_sync(self: Arc, fast_mode: bool) { i!("Starting background catch-up of older DAGs (newest-to-oldest)"); // The newest slot is already synced by phase 1; collect the rest of the // retention window below it. let mut last_newest = self.event_graph.current_genesis.read().await.header.timestamp; let mut pending: Vec = (1..DAGS_COUNT).map(|i| last_newest.saturating_sub(i * HOUR_MS)).collect(); loop { // Re-read `current_genesis` before each retry round: rotation may // have shifted the newest slot or dropped a pending slot. let latest_ts = self.event_graph.current_genesis.read().await.header.timestamp; // A newer DAG rotated in: it is the new newest slot and must sync // before the remaining older pending DAGs. if latest_ts > last_newest { i!("Syncing newly rotated-in DAG ({latest_ts})"); loop { match self.sync_dag_slot(latest_ts, fast_mode).await { Ok(()) => break, Err(e) => { e!("Failed syncing newly rotated-in DAG ({latest_ts}): {e}, cooling off"); sleep(COOLOFF_SLEEP_TIME).await; } } } last_newest = latest_ts; pending = (1..DAGS_COUNT).map(|i| latest_ts.saturating_sub(i * HOUR_MS)).collect(); } // Clamp the walk to the retention window: slots rotated out fall // below the horizon and are skipped rather than retried forever. let horizon = latest_ts.saturating_sub((DAGS_COUNT - 1) * HOUR_MS); let mut still_pending = Vec::new(); for dag_ts in pending.iter().copied() { if dag_ts < horizon { i!("Skipping rotated-out DAG ({dag_ts})"); continue } match self.sync_dag_slot(dag_ts, fast_mode).await { Ok(()) => { i!("Synced older DAG ({dag_ts})"); } Err(e) => { e!("Failed syncing older DAG ({dag_ts}): {e}, will retry after cooldown"); still_pending.push(dag_ts); sleep(COOLOFF_SLEEP_TIME).await; } } } pending = still_pending; if pending.is_empty() { i!("Background catch-up complete; all older DAGs synced"); break } } } /// Send a notification when there's a change in number of peers or the DAG sync status /// /// The node is only borrowed to grab the signal, so no strong node reference /// is held across the trigger below. Otherwise a notify racing `SceneNode::setup`'s /// strong_count assertion would panic the app. pub async fn notify_connect(&self, peers_count: usize, is_dag_synced: bool) { let sig = { let node = self.node.upgrade().unwrap(); node.get_signal("connect").unwrap() }; sig.trigger(serialize(&(peers_count as u32, is_dag_synced))).await; } /// Update the `outbound_peers` property with the outgoing connection slots addrs. /// Allows us to monitor the network state of our p2p node. /// Intermediate states append ` (status)` to the addr. async fn relay_outbound_slots(dnet_sub: Subscription, prop: PropertyPtr) { loop { let event = dnet_sub.receive().await; let (slot, kind, addr) = match event { DnetEvent::OutboundSlotConnected(info) => { (info.slot, "connected", Some(info.addr.to_string())) } DnetEvent::OutboundSlotConnecting(info) => { (info.slot, "connecting", Some(info.addr.to_string())) } DnetEvent::OutboundSlotDisconnected(info) => (info.slot, "disconnected", None), DnetEvent::OutboundSlotSleeping(info) => (info.slot, "sleeping", None), _ => continue, }; let mut atom = PropertyAtomicGuard::none(); let idx = slot as usize; assert!(idx < prop.get_len()); match addr { Some(addr) if kind == "connected" => { prop.set_str(&mut atom, Role::Internal, idx, addr).unwrap(); } Some(addr) => { let val = format!("{addr} ({kind})"); prop.set_str(&mut atom, Role::Internal, idx, val).unwrap(); } None => prop.set_null(&mut atom, Role::Internal, idx).unwrap(), } } } async fn relay_events(self: Arc, ev_sub: Subscription) { loop { let ev = ev_sub.receive().await; // Try to deserialize the `Event`'s content into a `Privmsg` let privmsg: Privmsg = match deserialize_async(ev.content()).await { Ok(v) => v, Err(e) => { e!("[IRC CLIENT] Failed deserializing incoming Privmsg event: {}", e); continue } }; // Route the message. An already-decrypted (plaintext) message names a // channel we hold directly: encrypted channels arrive as base58 // ciphertext, so a channel key we recognise is plaintext by definition // and is accepted as-is. Anything else must decrypt as a channel or DM; // undecryptable traffic (base58 garbage in neither map) is silently dropped. let mut privmsg = privmsg; // Is this a plaintext channel? let is_plaintext = self.channels.read().await.contains_key(&privmsg.channel); if !is_plaintext && !self.try_decrypt(&mut privmsg, &self.nick.get()).await { continue; } let mut timest = ev.header.timestamp; let msg_id = msg_id(&privmsg, timest); t!( "Relaying ev_id={:?}, ev={ev:?}, msg_id={msg_id}, privmsg={privmsg:?}, timest={timest}", ev.id(), ); let is_self = { let mut is_self = false; let mut seen = self.seen_msgs.lock(); match seen.get_status(&msg_id) { Some(msg) => { is_self = msg.is_self; if !msg.is_self || msg.seen_times > 1 { w!("Skipping duplicate seen message: {msg_id}"); continue } } None => { seen.push(msg_id.clone(), false); } } is_self }; // This is a hack to make messages appear sequentially in the UI let now_timest = UNIX_EPOCH.elapsed().unwrap().as_millis() as u64; if !is_self && timest.abs_diff(now_timest) < RECENT_TIME_DIST { d!("Applied timestamp correction: <{timest}> => <{now_timest}>"); timest = now_timest; } // Workaround for the chatview hack. This nick is off limits! let mut nick = privmsg.nick; if nick == "NOTICE" { nick = "noticer".to_string(); } self.notify_recv(privmsg.channel, timest, msg_id, nick, privmsg.msg).await; } } /// Send a notification about a new received message pub async fn notify_recv( &self, channel: String, timestamp: Timestamp, id: MessageId, nick: String, msg: String, ) { assert!( channel.starts_with('#') || channel.starts_with('@'), "notify_recv channel must be a \"#name\" channel or \"@name\" DM, got: {channel}" ); let mut arg_data = vec![]; channel.encode(&mut arg_data).unwrap(); timestamp.encode(&mut arg_data).unwrap(); id.encode(&mut arg_data).unwrap(); nick.encode(&mut arg_data).unwrap(); msg.encode(&mut arg_data).unwrap(); let sig = { let node = self.node.upgrade().unwrap(); node.get_signal("recv").unwrap() }; sig.trigger(arg_data).await; } async fn process_send(me: &Weak, sub: &MethodCallSub) -> bool { let Ok(method_call) = sub.receive().await else { d!("Event relayer closed"); return false }; t!("method called: send({method_call:?})"); assert!(method_call.send_res.is_none()); fn decode_data(data: &[u8]) -> std::io::Result<(Timestamp, String, String)> { let mut cur = Cursor::new(&data); let timest = Timestamp::decode(&mut cur).unwrap(); let channel = String::decode(&mut cur)?; let msg = String::decode(&mut cur)?; Ok((timest, channel, msg)) } let Ok((timest, channel, msg)) = decode_data(&method_call.data) else { e!("send() method invalid arg data"); return true }; let Some(self_) = me.upgrade() else { // Should not happen panic!("self destroyed before send_method_task was stopped!"); }; self_.handle_send(timest, channel, msg).await; true } /// User wants to send a msg async fn handle_send(&self, timest: Timestamp, channel: String, msg: String) { let nick = self.nick.get(); // Send text to channel d!("Sending privmsg: {timest} {channel}: <{nick}> {msg}"); let mut msg = Privmsg { version: 0, msg_type: 0, channel, nick, msg }; // DM layers use the "@name" UI id; strip it to the bare contact key and // require the contact to exist, else refuse to broadcast. if let Some(bare) = msg.channel.strip_prefix('@').map(str::to_string) { msg.channel = bare; if self.try_encrypt_dm(&mut msg).await.is_err() { e!("Refusing to send DM to unknown contact"); return; } } else { assert!(msg.channel.starts_with('#'), "channel name must start with #"); self.try_encrypt_channel(&mut msg).await; } let evgr = self.event_graph.clone(); let event = event_graph::Event::with_timestamp(timest, serialize_async(&msg).await, &evgr) .await .unwrap(); let msg_id = msg_id(&msg, timest); // Keep track of our own messages so we don't apply timestamp correction to them // which messes up the msg id. { let mut seen = self.seen_msgs.lock(); seen.push(msg_id.clone(), true); } // Broadcast the msg let current_genesis = self.event_graph.current_genesis.read().await; let dag_name = current_genesis.header.timestamp.to_string(); if let Err(e) = evgr.insert_signal_with_blob(&event, &[], &dag_name).await { e!("Failed inserting new event to DAG: {}", e); } if let Err(e) = self.p2p.broadcast(&EventPut(event, vec![])).await { e!("Event broadcast was not admitted: {e}"); } } /// Load channels from the app database and populate encryption keys pub async fn load_channels_from_db(&self) { let mut channels = self.channels.write().await; channels.clear(); let joined: HashSet = read_joined_channels().into_iter().collect(); for ui_channel in self.app_db.channels().await.unwrap() { let full_name = format!("#{}", ui_channel.name); if !joined.contains(&full_name) { continue } // Convert to IrcChannel with encryption let mut irc_channel = IrcChannel { topic: String::new(), nicks: HashSet::new(), saltbox: None }; if let Some(secret) = ui_channel.secret { // Convert secret array to SecretKey first, then derive PublicKey let secret_key = SecretKey::from_bytes(secret); let public = secret_key.public_key(); let saltbox = ChaChaBox::new(&public, &secret_key); // The secret in base58 for debugging //let secret_b58 = bs58::encode(secret).into_string(); irc_channel.saltbox = Some(Arc::new(saltbox)); } let is_encrypted = irc_channel.saltbox.is_some(); channels.insert(full_name, irc_channel); i!("Loaded channel: #{} (encrypted: {})", ui_channel.name, is_encrypted); } } /// Load contacts from the app database and build their encryption boxes. pub async fn load_contacts_from_db(&self) { let mut contacts = self.contacts.write().await; contacts.clear(); let joined: HashSet = read_joined_channels().into_iter().collect(); for contact in self.app_db.contacts().await.unwrap() { if !joined.contains(&format!("@{}", contact.name)) { continue } let their_public = PublicKey::from(contact.public); let saltbox = Arc::new(ChaChaBox::new(&their_public, &self.dm_secret)); let self_saltbox = Arc::new(ChaChaBox::new(&self.dm_secret.public_key(), &self.dm_secret)); contacts.insert(contact.name.clone(), IrcContact { saltbox, self_saltbox }); i!("Loaded contact: {}", contact.name); } } async fn rescan_channel_history(self: Arc, channel: String) { i!("Starting background rescan for channel: {channel}"); // Fetch and order all events from the DAG (like darkirc does) let Ok(dag_events) = self.event_graph.order_events().await else { e!("Failed to fetch events from DAG"); return; }; let mut found_count = 0; for event in dag_events.iter() { // Deserialize Privmsg let mut privmsg = match deserialize_async::(event.content()).await { Ok(pm) => pm, Err(e) => { t!("Not a Privmsg event, skipping"); continue; } }; // Try to decrypt (handles encrypted channels) self.try_decrypt(&mut privmsg, &self.nick.get()).await; // Check if message belongs to target channel if privmsg.channel != channel { continue; } found_count += 1; // Calculate message ID let timest = event.header.timestamp; let msg_id = msg_id(&privmsg, timest); // Send to ChatView via notify_recv (handles DB storage and duplicates) self.notify_recv( channel.clone(), timest, msg_id, privmsg.nick.clone(), privmsg.msg.clone(), ) .await; } i!("Rescan complete for {channel}: found {found_count} messages"); } async fn process_rescan(me: &Weak, sub: &MethodCallSub) -> bool { let Ok(method_call) = sub.receive().await else { d!("Rescan method closed"); return false }; t!("method called: rescan({method_call:?})"); let Some(self_) = me.upgrade() else { e!("DarkIrc destroyed before rescan completed"); return false }; // Decode channel name from method data let mut cur = std::io::Cursor::new(&method_call.data); let Ok(channel) = String::decode(&mut cur) else { e!("Rescan method called with invalid channel data"); return false }; self_.load_channels_from_db().await; self_.load_contacts_from_db().await; let task = self_.ex.clone().spawn(self_.clone().rescan_channel_history(channel)); self_.tasks.lock().push(task); true } /// Apply the transport-specific P2P configuration (profiles, seeds, /// active profiles) using the defaults from darkirc_config.toml fn apply_transport_settings(settings: &mut NetSettings, transport: &str) { settings.seeds.clear(); settings.profiles.clear(); settings.active_profiles.clear(); match transport { "tor" => { i!("Setup P2P network [tor]"); let mut tor_profile = NetworkProfile::tor_default(); tor_profile.outbound_connect_timeout = 60; settings.profiles.insert("tor".to_string(), tor_profile); settings.outbound_peer_discovery_cooloff_time = 60; settings.seeds.push( url::Url::parse( "tor://wgxxaifz5gv4iggcflyl67lgmsihffs6bbwobqah4np52t3y3olrnpid.onion:9601", ) .unwrap(), ); settings.seeds.push( url::Url::parse( "tor://inx5s3pdzddvgb5ii3oydutmbvw6fvor3oqu65wtxl3pyevtvrdn4had.onion:9601", ) .unwrap(), ); settings.active_profiles.push("tor".to_string()); } "tcp" => { i!("Setup P2P network [clearnet]"); let mut profile = NetworkProfile::default(); profile.outbound_connect_timeout = 40; profile.channel_handshake_timeout = 30; settings.profiles.insert("tcp+tls".to_string(), profile); settings.seeds.push(url::Url::parse("tcp+tls://lilith0.dark.fi:9600").unwrap()); settings.seeds.push(url::Url::parse("tcp+tls://lilith1.dark.fi:9600").unwrap()); settings.active_profiles.push("tcp+tls".to_string()); } unhandled => panic!("Unhandled net.transport value: {unhandled}"), } } /// `net.transport` was switched, reconfigure the P2P network and restart it async fn handle_transport_change(&self, transport: String) { i!("Transport changed to {transport}, restarting P2P network"); let was_started = self.chat_is_enabled.get(); if was_started { self.p2p.clone().stop().await; } let settings_lock = self.p2p.settings(); let mut settings = settings_lock.write().await; Self::apply_transport_settings(&mut settings, &transport); drop(settings); if was_started { while let Err(err) = self.p2p.clone().start().await { e!("Failed to start P2P network: {err}!"); e!("Retrying in {P2P_RETRY_TIME} secs"); sleep(P2P_RETRY_TIME).await; } let peers_count = self.p2p.peers_count(); self.notify_connect(peers_count, self.event_graph.is_synced()).await; } i!("P2P transport restart completed"); } /// `chat.is_enabled` was switched on async fn handle_start(&self) { i!("Manual P2P start triggered"); while let Err(err) = self.p2p.clone().start().await { e!("Failed to start P2P network: {err}!"); e!("Retrying in {P2P_RETRY_TIME} secs"); sleep(P2P_RETRY_TIME).await; } let peers_count = self.p2p.peers_count(); self.notify_connect(peers_count, self.event_graph.is_synced()).await; i!("P2P start completed"); } /// `chat.is_enabled` was switched off async fn handle_stop(&self) { i!("Manual P2P stop triggered"); self.p2p.clone().stop().await; // Stopped outbound slots emit no dnet events, so clear the property here let node = self.node.upgrade().unwrap(); let prop = node.get_property("outbound_peers").unwrap(); let mut atom = PropertyAtomicGuard::none(); for idx in 0..prop.get_len() { prop.set_null(&mut atom, Role::Internal, idx).unwrap(); } self.notify_connect(0, self.event_graph.is_synced()).await; i!("P2P stop completed"); } async fn set_outbound_connections(&self, count: usize) { let p2p_settings = self.p2p.settings(); if p2p_settings.read().await.outbound_connections == count { return; } p2p_settings.write().await.outbound_connections = count; self.p2p.clone().reload().await; } async fn start(self: Arc, sg_root: SceneNodePtr, ex: ExecutorPtr) { i!("Registering EventGraph P2P protocol"); let event_graph_ = Arc::clone(&self.event_graph); let registry = self.p2p.protocol_registry(); registry .register(SESSION_DEFAULT, move |channel, _| { let event_graph_ = event_graph_.clone(); async move { ProtocolEventGraph::init(event_graph_, channel).await.unwrap() } }) .await; let me = Arc::downgrade(&self); let node = &self.node.upgrade().unwrap(); let method_sub = node.subscribe_method_call("send").unwrap(); let me2 = me.clone(); let send_method_task = ex.spawn(async move { while Self::process_send(&me2, &method_sub).await {} }); let chat_is_enabled = self.chat_is_enabled.clone(); let chat_is_enabled_sub = chat_is_enabled.prop().subscribe_modify(); let me2 = me.clone(); let setting_task = ex.spawn(async move { if chat_is_enabled.get() { let Some(self_) = me2.upgrade() else { return }; self_.handle_start().await; } while let Ok(_) = chat_is_enabled_sub.receive().await { let Some(self_) = me2.upgrade() else { break }; if chat_is_enabled.get() { self_.handle_start().await; } else { self_.handle_stop().await; } } }); let net_transport = self.net_transport.clone(); let net_transport_sub = net_transport.prop().subscribe_modify(); let me2 = me.clone(); let transport_task = ex.spawn(async move { while let Ok(_) = net_transport_sub.receive().await { let Some(self_) = me2.upgrade() else { break }; self_.handle_transport_change(net_transport.get()).await; } }); let rescan_method_sub = node.subscribe_method_call("rescan").unwrap(); let me2 = me.clone(); let rescan_method_task = ex.spawn(async move { while Self::process_rescan(&me2, &rescan_method_sub).await {} }); let mut on_modify = OnModify::new(ex.clone(), self.node.clone(), me.clone()); async fn save_nick(self_: Arc, _batch: BatchGuardPtr) { if let Err(err) = self_.app_db.nick_set(&self_.nick.get()).await { e!("Failed persisting nick to app db: {err}"); } } on_modify.when_change(self.nick.prop(), save_nick); let ev_sub = self.event_graph.event_subscribe().await; let ev_task = ex.spawn(self.clone().relay_events(ev_sub)); // Sync the DAG / check sync status let channel_sub = self.p2p.hosts().subscribe_channel().await; let dag_task = ex.spawn(self.clone().dag_sync(channel_sub)); let window_node = sg_root.lookup_node("/window").unwrap(); let (start_slot, start_recv) = Slot::new("darkirc_start"); window_node.register("start", start_slot).unwrap(); let me2 = Arc::downgrade(&self); let start_task = ex.spawn(async move { while let Ok(_) = start_recv.recv().await { let Some(self_) = me2.upgrade() else { break }; self_.set_outbound_connections(P2P_OUTBOUND_ACTIVE).await; } }); let (stop_slot, stop_recv) = Slot::new("darkirc_stop"); window_node.register("stop", stop_slot).unwrap(); let me2 = Arc::downgrade(&self); let stop_task = ex.spawn(async move { while let Ok(_) = stop_recv.recv().await { let Some(self_) = me2.upgrade() else { break }; self_.set_outbound_connections(P2P_OUTBOUND_SLEEP).await; } }); let (screen_changed_slot, screen_changed_recv) = Slot::new("darkirc_screen_changed"); window_node.register("screen_changed", screen_changed_slot).unwrap(); let me2 = Arc::downgrade(&self); let screen_changed_task = ex.spawn(async move { while let Ok(data) = screen_changed_recv.recv().await { let Some(self_) = me2.upgrade() else { break }; let mut cursor = Cursor::new(&data); let Ok(screen_on) = bool::decode(&mut cursor) else { continue }; if screen_on { self_.set_outbound_connections(P2P_OUTBOUND_ACTIVE).await; } else { self_.set_outbound_connections(P2P_OUTBOUND_SLEEP).await; } } }); let mut tasks = vec![ send_method_task, setting_task, transport_task, rescan_method_task, ev_task, dag_task, start_task, stop_task, screen_changed_task, ]; if DNET_ENABLED { let dnet_sub = self.p2p.dnet_subscribe().await; let node = self.node.upgrade().unwrap(); let prop = node.get_property("outbound_peers").unwrap(); let dnet_task = ex.spawn(Self::relay_outbound_slots(dnet_sub, prop)); tasks.push(dnet_task); } tasks.append(&mut on_modify.tasks); *self.tasks.lock() = tasks; } /// Encrypt a channel `Privmsg` in place if the channel has a shared key. /// Open channels with no key are left plaintext. pub async fn try_encrypt_channel(&self, privmsg: &mut Privmsg) { let guard = self.channels.read().await; let Some((name, channel)) = guard.get_key_value(&privmsg.channel) else { return; }; let Some(saltbox) = &channel.saltbox else { return; }; privmsg.channel = saltbox::encrypt(saltbox, &[0x00; MAX_NICK_LEN]); privmsg.nick = saltbox::encrypt(saltbox, &pad(&privmsg.nick)); privmsg.msg = saltbox::encrypt(saltbox, privmsg.msg.as_bytes()); d!("Successfully encrypted message for {name}"); } /// Encrypt a DM `Privmsg` in place for the contact named by `privmsg.channel` /// (the bare key, with no leading "@"). Fails if the contact is unknown so /// the caller can refuse to broadcast a message no one could decrypt. pub async fn try_encrypt_dm(&self, privmsg: &mut Privmsg) -> Result<()> { let guard = self.contacts.read().await; let Some((name, contact)) = guard.get_key_value(&privmsg.channel) else { return Err(Error::ContactNotFound); }; privmsg.channel = saltbox::encrypt(&contact.saltbox, &[0x00; MAX_NICK_LEN]); privmsg.nick = saltbox::encrypt(&contact.self_saltbox, &[0x00; MAX_NICK_LEN]); privmsg.msg = saltbox::encrypt(&contact.saltbox, privmsg.msg.as_bytes()); d!("Successfully encrypted DM for {name}"); Ok(()) } /// Try decrypting a `Privmsg` as a channel message in place. Returns true on /// success. Plaintext messages for a known keyless channel are accepted /// as-is; everything else returns false. pub async fn try_decrypt_channel(&self, privmsg: &mut Privmsg) -> bool { let Ok(channel_ciphertext) = bs58::decode(&privmsg.channel).into_vec() else { // Not encrypted: accept only if it names a channel we hold. return self.channels.read().await.contains_key(&privmsg.channel); }; let Ok(nick_ciphertext) = bs58::decode(&privmsg.nick).into_vec() else { return false }; let Ok(msg_ciphertext) = bs58::decode(&privmsg.msg).into_vec() else { return false }; for (name, channel) in self.channels.read().await.iter() { let Some(saltbox) = &channel.saltbox else { continue }; if saltbox::try_decrypt(saltbox, &channel_ciphertext).is_none() { continue }; let Some(mut nick_dec) = saltbox::try_decrypt(saltbox, &nick_ciphertext) else { w!("Could not decrypt nick ciphertext for channel: {name}"); continue }; let Some(msg_dec) = saltbox::try_decrypt(saltbox, &msg_ciphertext) else { w!("Could not decrypt message ciphertext for channel: {name}"); continue }; unpad(&mut nick_dec); privmsg.channel = name.to_string(); privmsg.nick = String::from_utf8_lossy(&nick_dec).into(); privmsg.msg = String::from_utf8_lossy(&msg_dec).into(); d!("Successfully decrypted message for {name}"); return true } false } /// Try decrypting a `Privmsg` as a DM in place. Returns true on success. pub async fn try_decrypt_contact(&self, privmsg: &mut Privmsg, self_nickname: &str) -> bool { let Ok(channel_ciphertext) = bs58::decode(&privmsg.channel).into_vec() else { return false }; let Ok(nick_ciphertext) = bs58::decode(&privmsg.nick).into_vec() else { return false }; let Ok(msg_ciphertext) = bs58::decode(&privmsg.msg).into_vec() else { return false }; for (name, contact) in self.contacts.read().await.iter() { if saltbox::try_decrypt(&contact.saltbox, &channel_ciphertext).is_none() { continue }; let nick = if saltbox::try_decrypt(&contact.self_saltbox, &nick_ciphertext).is_some() { String::from(self_nickname) } else { name.to_string() }; let Some(msg_dec) = saltbox::try_decrypt(&contact.saltbox, &msg_ciphertext) else { w!("Could not decrypt message ciphertext for contact: {name}"); continue }; privmsg.channel = format!("@{}", name); privmsg.nick = nick; privmsg.msg = String::from_utf8_lossy(&msg_dec).into(); return true } false } /// Try decrypting a given potentially encrypted `Privmsg` object as a channel /// and then as a DM. Returns true if either succeeded. pub async fn try_decrypt(&self, privmsg: &mut Privmsg, self_nickname: &str) -> bool { self.try_decrypt_channel(privmsg).await || self.try_decrypt_contact(privmsg, self_nickname).await } } pub fn msg_id(privmsg: &Privmsg, timest: u64) -> MessageId { let mut hasher = blake3::Hasher::new(); 0u8.encode(&mut hasher).unwrap(); 0u8.encode(&mut hasher).unwrap(); timest.encode(&mut hasher).unwrap(); privmsg.channel.encode(&mut hasher).unwrap(); privmsg.nick.encode(&mut hasher).unwrap(); privmsg.msg.encode(&mut hasher).unwrap(); MessageId(hasher.finalize().into()) }