Skip to main content

rns_ctl/cmd/
http.rs

1//! HTTP/WebSocket control server subcommand.
2
3use std::net::SocketAddr;
4use std::path::Path;
5use std::sync::{Arc, Mutex};
6use std::thread;
7use std::time::Duration;
8
9use rns_crypto::identity::Identity;
10use rns_crypto::Rng;
11
12use crate::api;
13use crate::args::Args;
14use crate::{bridge, config, encode, server, state};
15
16pub fn run(args: Args) {
17    if let Err(err) = run_embedded(args, HttpRunOptions::standalone()) {
18        eprintln!("rns-ctl http: {}", err);
19        std::process::exit(1);
20    }
21}
22
23#[derive(Debug, Clone, Copy)]
24pub struct HttpRunOptions {
25    pub init_logging: bool,
26    pub install_signal_handler: bool,
27}
28
29impl HttpRunOptions {
30    pub fn standalone() -> Self {
31        Self {
32            init_logging: true,
33            install_signal_handler: true,
34        }
35    }
36
37    pub fn embedded() -> Self {
38        Self {
39            init_logging: false,
40            install_signal_handler: false,
41        }
42    }
43}
44
45pub fn run_embedded(args: Args, options: HttpRunOptions) -> Result<(), String> {
46    if args.has("help") {
47        print_help();
48        return Ok(());
49    }
50
51    if args.has("version") {
52        println!("rns-ctl {}", env!("FULL_VERSION"));
53        return Ok(());
54    }
55
56    let prepared = prepare_embedded_with_state(args, options, None)?;
57    server::run_server(prepared.addr, prepared.ctx).map_err(|e| format!("server error: {}", e))
58}
59
60pub struct PreparedHttpServer {
61    pub addr: SocketAddr,
62    pub ctx: Arc<server::ServerContext>,
63}
64
65pub fn prepare_embedded_with_state(
66    args: Args,
67    options: HttpRunOptions,
68    shared_state_override: Option<state::SharedState>,
69) -> Result<PreparedHttpServer, String> {
70    if options.init_logging {
71        // Init logging
72        let log_level = match args.verbosity {
73            0 => "info",
74            1 => "debug",
75            _ => "trace",
76        };
77        if std::env::var("RUST_LOG").is_err() {
78            std::env::set_var(
79                "RUST_LOG",
80                format!(
81                    "rns_ctl={},rns_net={},rns_hooks={}",
82                    log_level, log_level, log_level
83                ),
84            );
85        }
86        let _ = env_logger::try_init();
87    }
88
89    let mut cfg = config::from_args_and_env(&args);
90
91    // Generate a random auth token if none provided and auth is not disabled
92    if cfg.auth_token.is_none() && !cfg.disable_auth {
93        let mut token_bytes = [0u8; 24];
94        rns_crypto::OsRng.fill_bytes(&mut token_bytes);
95        let token = encode::to_hex(&token_bytes);
96        log::info!("Generated auth token: {}", token);
97        println!("Auth token: {}", token);
98        cfg.auth_token = Some(token);
99    }
100
101    // Create shared state and broadcast registry
102    let shared_state = shared_state_override
103        .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(state::CtlState::new())));
104    let ws_broadcast: state::WsBroadcast = Arc::new(Mutex::new(Vec::new()));
105
106    // Resolve config path and expose local identity before the shared node client is ready.
107    let config_path = cfg.config_path.as_deref().map(Path::new);
108    load_identity_into_state(config_path, &shared_state);
109
110    // Wrap node for shared access. Daemon mode attaches the shared client asynchronously
111    // so the HTTP control plane can bind even while rnsd is still bringing up slow peers.
112    let node_handle: api::NodeHandle = Arc::new(Mutex::new(None));
113    let node_for_shutdown = node_handle.clone();
114
115    // Store node handle in shared state so callbacks can access it
116    {
117        let mut s = state::write_state(&shared_state);
118        s.node_handle = Some(node_handle.clone());
119    }
120
121    if cfg.daemon_mode {
122        start_shared_node_connector(
123            cfg.clone(),
124            shared_state.clone(),
125            ws_broadcast.clone(),
126            node_handle.clone(),
127        );
128    } else {
129        let callbacks = Box::new(bridge::CtlCallbacks::new(
130            shared_state.clone(),
131            ws_broadcast.clone(),
132        ));
133        log::info!("Starting RNS node...");
134        let node = rns_net::RnsNode::from_config(config_path, callbacks)
135            .map_err(|e| format!("failed to start node: {}", e))?;
136        *state::lock_node_handle(&node_handle) = Some(node);
137    }
138
139    // Set up ctrl-c handler
140    if options.install_signal_handler {
141        let shutdown_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
142        let shutdown_flag_handler = shutdown_flag.clone();
143
144        ctrlc_handler(move || {
145            if shutdown_flag_handler.swap(true, std::sync::atomic::Ordering::SeqCst) {
146                std::process::exit(1);
147            }
148            log::info!("Shutting down...");
149            if let Some(node) = state::lock_node_handle(&node_for_shutdown).take() {
150                node.shutdown();
151            }
152            std::process::exit(0);
153        });
154    } else {
155        let _ = node_for_shutdown;
156    }
157
158    // Validate and load TLS config
159    #[cfg(feature = "tls")]
160    let tls_config = {
161        match (&cfg.tls_cert, &cfg.tls_key) {
162            (Some(cert), Some(key)) => match crate::tls::load_tls_config(cert, key) {
163                Ok(config) => {
164                    log::info!("TLS enabled with cert={} key={}", cert, key);
165                    Some(config)
166                }
167                Err(e) => {
168                    return Err(format!("failed to load TLS config: {}", e));
169                }
170            },
171            (Some(_), None) | (None, Some(_)) => {
172                return Err("both --tls-cert and --tls-key must be provided together".into());
173            }
174            (None, None) => None,
175        }
176    };
177
178    #[cfg(not(feature = "tls"))]
179    {
180        if cfg.tls_cert.is_some() || cfg.tls_key.is_some() {
181            return Err(
182                "TLS options require the 'tls' feature. Rebuild with: cargo build --features tls"
183                    .into(),
184            );
185        }
186    }
187
188    // Build server context
189    let config_handle = Arc::new(std::sync::RwLock::new(cfg));
190    state::set_control_plane_config(&shared_state, config_handle.clone());
191    let ctx = Arc::new(server::ServerContext {
192        node: node_handle,
193        state: shared_state,
194        ws_broadcast,
195        config: config_handle.clone(),
196        #[cfg(feature = "tls")]
197        tls_config,
198    });
199
200    let cfg = state::read_control_plane_config(&config_handle);
201    let addr: SocketAddr = format!("{}:{}", cfg.host, cfg.port)
202        .parse()
203        .map_err(|_| "invalid bind address".to_string())?;
204
205    Ok(PreparedHttpServer { addr, ctx })
206}
207
208fn load_identity_into_state(config_path: Option<&Path>, shared_state: &state::SharedState) {
209    let config_dir = rns_net::storage::resolve_config_dir(config_path);
210    let paths = rns_net::storage::ensure_storage_dirs(&config_dir).ok();
211    let identity: Option<Identity> = paths
212        .as_ref()
213        .and_then(|p| rns_net::storage::load_or_create_identity(&p.identities).ok());
214
215    let mut s = state::write_state(shared_state);
216    if let Some(ref id) = identity {
217        s.identity_hash = Some(*id.hash());
218        // Identity doesn't impl Clone; copy via private key.
219        if let Some(prv) = id.get_private_key() {
220            s.identity = Some(Identity::from_private_key(&prv));
221        }
222    }
223}
224
225fn start_shared_node_connector(
226    cfg: config::CtlConfig,
227    shared_state: state::SharedState,
228    ws_broadcast: state::WsBroadcast,
229    node_handle: api::NodeHandle,
230) {
231    thread::Builder::new()
232        .name("rns-ctl-shared-client".into())
233        .spawn(move || {
234            let mut attempt: u64 = 0;
235            log::info!("Connecting as shared client (daemon mode)");
236            loop {
237                attempt += 1;
238                let callbacks = Box::new(bridge::CtlCallbacks::new(
239                    shared_state.clone(),
240                    ws_broadcast.clone(),
241                ));
242                let config_path = cfg.config_path.as_deref().map(Path::new);
243
244                match rns_net::RnsNode::connect_shared_from_config(config_path, callbacks) {
245                    Ok(node) => {
246                        *state::lock_node_handle(&node_handle) = Some(node);
247                        log::info!("connected embedded HTTP control plane to shared rnsd");
248                        return;
249                    }
250                    Err(err) => {
251                        if attempt == 1 || attempt % 10 == 0 {
252                            log::warn!(
253                                "shared rnsd not ready for embedded HTTP control plane (attempt {}): {}",
254                                attempt,
255                                err
256                            );
257                        } else {
258                            log::debug!(
259                                "shared rnsd not ready for embedded HTTP control plane (attempt {}): {}",
260                                attempt,
261                                err
262                            );
263                        }
264                        thread::sleep(Duration::from_millis(500));
265                    }
266                }
267            }
268        })
269        .ok();
270}
271
272/// Set up a ctrl-c signal handler.
273fn ctrlc_handler<F: FnOnce() + Send + 'static>(handler: F) {
274    let handler = Mutex::new(Some(handler));
275    libc_signal(move || {
276        if let Some(f) = match handler.lock() {
277            Ok(guard) => guard,
278            Err(poisoned) => {
279                log::error!("recovering from poisoned signal handler lock");
280                poisoned.into_inner()
281            }
282        }
283        .take()
284        {
285            f();
286        }
287    });
288}
289
290/// Register a SIGINT handler using libc, polling in a background thread.
291fn libc_signal<F: FnMut() + Send + 'static>(mut callback: F) {
292    std::thread::Builder::new()
293        .name("signal-handler".into())
294        .spawn(move || {
295            use std::sync::atomic::{AtomicBool, Ordering};
296            static SIGNALED: AtomicBool = AtomicBool::new(false);
297
298            #[cfg(unix)]
299            {
300                extern "C" fn sig_handler(_: i32) {
301                    SIGNALED.store(true, std::sync::atomic::Ordering::SeqCst);
302                }
303                unsafe {
304                    libc_ffi::signal(libc_ffi::SIGINT, sig_handler as *const () as usize);
305                }
306            }
307
308            loop {
309                std::thread::sleep(Duration::from_millis(100));
310                if SIGNALED.swap(false, Ordering::SeqCst) {
311                    callback();
312                    break;
313                }
314            }
315        })
316        .ok();
317}
318
319#[cfg(unix)]
320mod libc_ffi {
321    extern "C" {
322        pub fn signal(sig: i32, handler: usize) -> usize;
323    }
324    pub const SIGINT: i32 = 2;
325}
326
327fn print_help() {
328    println!(
329        "rns-ctl http - HTTP/WebSocket control interface for Reticulum
330
331USAGE:
332    rns-ctl http [OPTIONS]
333
334OPTIONS:
335    -c, --config PATH       Path to RNS config directory
336    -p, --port PORT         HTTP port (default: 8080, env: RNSCTL_HTTP_PORT)
337    -H, --host HOST         Bind host (default: 127.0.0.1, env: RNSCTL_HOST)
338    -t, --token TOKEN       Auth bearer token (env: RNSCTL_AUTH_TOKEN)
339    -d, --daemon            Connect as client to running rnsd
340        --disable-auth      Disable authentication
341        --tls-cert PATH     TLS certificate file (env: RNSCTL_TLS_CERT, requires 'tls' feature)
342        --tls-key PATH      TLS private key file (env: RNSCTL_TLS_KEY, requires 'tls' feature)
343    -v                      Increase verbosity (repeat for more)
344    -h, --help              Show this help
345        --version           Show version"
346    );
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    fn args(values: &[&str]) -> Args {
354        Args::parse_from(values.iter().map(|value| value.to_string()).collect())
355    }
356
357    #[test]
358    fn daemon_mode_prepares_http_context_before_shared_rnsd_is_ready() {
359        let config_dir = std::env::temp_dir().join(format!(
360            "rns-ctl-http-daemon-not-ready-{}",
361            std::process::id()
362        ));
363        let _ = std::fs::create_dir_all(&config_dir);
364
365        let prepared = prepare_embedded_with_state(
366            args(&[
367                "--daemon",
368                "--disable-auth",
369                "--config",
370                config_dir.to_str().unwrap(),
371                "--host",
372                "127.0.0.1",
373                "--port",
374                "0",
375            ]),
376            HttpRunOptions::embedded(),
377            None,
378        )
379        .expect("daemon-mode HTTP context should not require rnsd RPC readiness");
380
381        assert_eq!(prepared.addr.ip().to_string(), "127.0.0.1");
382        assert_eq!(prepared.addr.port(), 0);
383        assert!(
384            prepared.ctx.node.lock().unwrap().is_none(),
385            "shared node client should attach asynchronously"
386        );
387        assert!(
388            prepared.ctx.state.read().unwrap().node_handle.is_some(),
389            "HTTP APIs should still receive a node handle placeholder"
390        );
391    }
392}