Skip to main content

rns_net/
shared_client.rs

1//! Shared instance client mode.
2//!
3//! Allows an RnsNode to connect as a client to an already-running Reticulum
4//! daemon, proxying operations through it. The client runs a minimal transport
5//! engine with `transport_enabled: false` — it does no routing of its own, but
6//! registers local destinations and sends/receives packets via the local
7//! connection.
8//!
9//! This matches Python's behavior when `share_instance = True` and a daemon
10//! is already running: the new process connects as a client rather than
11//! starting its own interfaces.
12
13use std::io;
14use std::path::Path;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::Arc;
17use std::thread;
18use std::time::Duration;
19
20use rns_core::transport::types::TransportConfig;
21
22use crate::driver::{Callbacks, Driver};
23use crate::event;
24use crate::interface::local::LocalClientConfig;
25use crate::interface::{InterfaceEntry, InterfaceStats};
26use crate::node::RnsNode;
27use crate::storage;
28use crate::time;
29
30/// Configuration for connecting as a shared instance client.
31pub struct SharedClientConfig {
32    /// Instance name for Unix socket namespace (e.g. "default" → `\0rns/default`).
33    pub instance_name: String,
34    /// TCP port to try if Unix socket fails (default 37428).
35    pub port: u16,
36    /// RPC control port for queries (default 37429).
37    pub rpc_port: u16,
38}
39
40impl Default for SharedClientConfig {
41    fn default() -> Self {
42        SharedClientConfig {
43            instance_name: "default".into(),
44            port: 37428,
45            rpc_port: 37429,
46        }
47    }
48}
49
50impl RnsNode {
51    /// Connect to an existing shared instance as a client.
52    ///
53    /// The client runs `transport_enabled: false` — it does no routing,
54    /// but can register destinations and send/receive packets through
55    /// the daemon.
56    pub fn connect_shared(
57        config: SharedClientConfig,
58        callbacks: Box<dyn Callbacks>,
59    ) -> io::Result<Self> {
60        let transport_config = TransportConfig {
61            transport_enabled: false,
62            identity_hash: None,
63        };
64
65        let (tx, rx) = event::channel();
66        let mut driver = Driver::new(transport_config, rx, callbacks);
67
68        // Connect to the daemon via LocalClientInterface
69        let local_config = LocalClientConfig {
70            name: "Local shared instance".into(),
71            instance_name: config.instance_name.clone(),
72            port: config.port,
73            interface_id: rns_core::transport::types::InterfaceId(1),
74            reconnect_wait: Duration::from_secs(8),
75        };
76
77        let id = local_config.interface_id;
78        let info = rns_core::transport::types::InterfaceInfo {
79            id,
80            name: "LocalInterface".into(),
81            mode: rns_core::constants::MODE_FULL,
82            out_capable: true,
83            in_capable: true,
84            bitrate: Some(1_000_000_000),
85            announce_rate_target: None,
86            announce_rate_grace: 0,
87            announce_rate_penalty: 0.0,
88            announce_cap: rns_core::constants::ANNOUNCE_CAP,
89            is_local_client: true,
90            wants_tunnel: false,
91            tunnel_id: None,
92        };
93
94        let writer = crate::interface::local::start_client(local_config, tx.clone())?;
95
96        driver.engine.register_interface(info.clone());
97        driver.interfaces.insert(
98            id,
99            InterfaceEntry {
100                id,
101                info,
102                writer,
103                online: false,
104                dynamic: false,
105                ifac: None,
106                stats: InterfaceStats {
107                    started: time::now(),
108                    ..Default::default()
109                },
110                interface_type: "LocalClientInterface".to_string(),
111            },
112        );
113
114        // Spawn timer thread with configurable tick interval
115        let tick_interval_ms = Arc::new(AtomicU64::new(1000));
116        let timer_tx = tx.clone();
117        let timer_interval = Arc::clone(&tick_interval_ms);
118        thread::Builder::new()
119            .name("rns-timer-client".into())
120            .spawn(move || {
121                loop {
122                    let ms = timer_interval.load(Ordering::Relaxed);
123                    thread::sleep(Duration::from_millis(ms));
124                    if timer_tx.send(event::Event::Tick).is_err() {
125                        break;
126                    }
127                }
128            })?;
129
130        // Spawn driver thread
131        let driver_handle = thread::Builder::new()
132            .name("rns-driver-client".into())
133            .spawn(move || {
134                driver.run();
135            })?;
136
137        Ok(RnsNode::from_parts(tx, driver_handle, None, tick_interval_ms))
138    }
139
140    /// Connect to a shared instance, with config loaded from a config directory.
141    ///
142    /// Reads the config file to determine instance_name and ports.
143    pub fn connect_shared_from_config(
144        config_path: Option<&Path>,
145        callbacks: Box<dyn Callbacks>,
146    ) -> io::Result<Self> {
147        let config_dir = storage::resolve_config_dir(config_path);
148
149        // Parse config file for instance settings
150        let config_file = config_dir.join("config");
151        let rns_config = if config_file.exists() {
152            crate::config::parse_file(&config_file).map_err(|e| {
153                io::Error::new(io::ErrorKind::InvalidData, format!("{}", e))
154            })?
155        } else {
156            crate::config::parse("").map_err(|e| {
157                io::Error::new(io::ErrorKind::InvalidData, format!("{}", e))
158            })?
159        };
160
161        let shared_config = SharedClientConfig {
162            instance_name: rns_config.reticulum.instance_name.clone(),
163            port: rns_config.reticulum.shared_instance_port,
164            rpc_port: rns_config.reticulum.instance_control_port,
165        };
166
167        Self::connect_shared(shared_config, callbacks)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use std::sync::atomic::{AtomicU64, Ordering};
175    use std::sync::mpsc;
176    use std::sync::Arc;
177
178    use crate::interface::local::LocalServerConfig;
179
180    struct NoopCallbacks;
181    impl Callbacks for NoopCallbacks {
182        fn on_announce(&mut self, _: crate::destination::AnnouncedIdentity) {}
183        fn on_path_updated(&mut self, _: rns_core::types::DestHash, _: u8) {}
184        fn on_local_delivery(&mut self, _: rns_core::types::DestHash, _: Vec<u8>, _: rns_core::types::PacketHash) {}
185    }
186
187    fn find_free_port() -> u16 {
188        std::net::TcpListener::bind("127.0.0.1:0")
189            .unwrap()
190            .local_addr()
191            .unwrap()
192            .port()
193    }
194
195    #[test]
196    fn connect_shared_to_tcp_server() {
197        let port = find_free_port();
198        let next_id = Arc::new(AtomicU64::new(50000));
199        let (server_tx, server_rx) = mpsc::channel();
200
201        // Start a local server
202        let server_config = LocalServerConfig {
203            instance_name: "test-shared-connect".into(),
204            port,
205            interface_id: rns_core::transport::types::InterfaceId(99),
206        };
207
208        crate::interface::local::start_server(server_config, server_tx, next_id).unwrap();
209        thread::sleep(Duration::from_millis(50));
210
211        // Connect as shared client
212        let config = SharedClientConfig {
213            instance_name: "test-shared-connect".into(),
214            port,
215            rpc_port: 0,
216        };
217
218        let node = RnsNode::connect_shared(config, Box::new(NoopCallbacks)).unwrap();
219
220        // Server should see InterfaceUp for the client
221        let event = server_rx.recv_timeout(Duration::from_secs(2)).unwrap();
222        assert!(matches!(event, crate::event::Event::InterfaceUp(_, _, _)));
223
224        node.shutdown();
225    }
226
227    #[test]
228    fn shared_client_register_destination() {
229        let port = find_free_port();
230        let next_id = Arc::new(AtomicU64::new(51000));
231        let (server_tx, _server_rx) = mpsc::channel();
232
233        let server_config = LocalServerConfig {
234            instance_name: "test-shared-reg".into(),
235            port,
236            interface_id: rns_core::transport::types::InterfaceId(98),
237        };
238
239        crate::interface::local::start_server(server_config, server_tx, next_id).unwrap();
240        thread::sleep(Duration::from_millis(50));
241
242        let config = SharedClientConfig {
243            instance_name: "test-shared-reg".into(),
244            port,
245            rpc_port: 0,
246        };
247
248        let node = RnsNode::connect_shared(config, Box::new(NoopCallbacks)).unwrap();
249
250        // Register a destination
251        let dest_hash = [0xAA; 16];
252        node.register_destination(
253            dest_hash,
254            rns_core::constants::DESTINATION_SINGLE,
255        )
256        .unwrap();
257
258        // Give time for event processing
259        thread::sleep(Duration::from_millis(100));
260
261        node.shutdown();
262    }
263
264    #[test]
265    fn shared_client_send_packet() {
266        let port = find_free_port();
267        let next_id = Arc::new(AtomicU64::new(52000));
268        let (server_tx, server_rx) = mpsc::channel();
269
270        let server_config = LocalServerConfig {
271            instance_name: "test-shared-send".into(),
272            port,
273            interface_id: rns_core::transport::types::InterfaceId(97),
274        };
275
276        crate::interface::local::start_server(server_config, server_tx, next_id).unwrap();
277        thread::sleep(Duration::from_millis(50));
278
279        let config = SharedClientConfig {
280            instance_name: "test-shared-send".into(),
281            port,
282            rpc_port: 0,
283        };
284
285        let node = RnsNode::connect_shared(config, Box::new(NoopCallbacks)).unwrap();
286
287        // Build a minimal packet and send it
288        let raw = vec![0x00, 0x00, 0xAA, 0xBB, 0xCC, 0xDD]; // minimal raw packet
289        node.send_raw(raw, rns_core::constants::DESTINATION_PLAIN, None)
290            .unwrap();
291
292        // Server should receive a Frame event from the client
293        // (the packet will be HDLC-framed over the local connection)
294        let mut saw_frame = false;
295        for _ in 0..10 {
296            match server_rx.recv_timeout(Duration::from_secs(1)) {
297                Ok(crate::event::Event::Frame { .. }) => {
298                    saw_frame = true;
299                    break;
300                }
301                Ok(_) => continue,
302                Err(_) => break,
303            }
304        }
305        // The packet may or may not arrive as a Frame depending on transport
306        // routing, so we don't assert on it — the important thing is no crash.
307
308        node.shutdown();
309    }
310
311    #[test]
312    fn connect_shared_fails_no_server() {
313        let port = find_free_port();
314
315        let config = SharedClientConfig {
316            instance_name: "nonexistent-instance-12345".into(),
317            port,
318            rpc_port: 0,
319        };
320
321        // Should fail because no server is running
322        let result = RnsNode::connect_shared(config, Box::new(NoopCallbacks));
323        assert!(result.is_err());
324    }
325
326    #[test]
327    fn shared_config_defaults() {
328        let config = SharedClientConfig::default();
329        assert_eq!(config.instance_name, "default");
330        assert_eq!(config.port, 37428);
331        assert_eq!(config.rpc_port, 37429);
332    }
333}