1use 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 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 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 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 let config_path = cfg.config_path.as_deref().map(Path::new);
108 load_identity_into_state(config_path, &shared_state);
109
110 let node_handle: api::NodeHandle = Arc::new(Mutex::new(None));
113 let node_for_shutdown = node_handle.clone();
114
115 {
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 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 #[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 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 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
272fn 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
290fn 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}