Skip to main content

rns_ctl/
config.rs

1use std::collections::HashMap;
2
3/// Configuration for rns-ctl.
4pub struct CtlConfig {
5    /// Bind host (default: "127.0.0.1").
6    pub host: String,
7    /// HTTP port (default: 8080).
8    pub port: u16,
9    /// Bearer token for auth. If None and !disable_auth, a random token is generated.
10    pub auth_token: Option<String>,
11    /// Skip auth entirely.
12    pub disable_auth: bool,
13    /// Path to RNS config directory.
14    pub config_path: Option<String>,
15    /// Connect as shared instance client (--daemon).
16    pub daemon_mode: bool,
17    /// TLS certificate path.
18    pub tls_cert: Option<String>,
19    /// TLS private key path.
20    pub tls_key: Option<String>,
21}
22
23impl Default for CtlConfig {
24    fn default() -> Self {
25        CtlConfig {
26            host: "127.0.0.1".into(),
27            port: 8080,
28            auth_token: None,
29            disable_auth: false,
30            config_path: None,
31            daemon_mode: false,
32            tls_cert: None,
33            tls_key: None,
34        }
35    }
36}
37
38/// Parsed command-line arguments.
39pub struct Args {
40    pub flags: HashMap<String, String>,
41    pub verbosity: u8,
42}
43
44impl Args {
45    pub fn parse() -> Self {
46        Self::parse_from(std::env::args().skip(1).collect())
47    }
48
49    pub fn parse_from(args: Vec<String>) -> Self {
50        let mut flags = HashMap::new();
51        let mut verbosity: u8 = 0;
52        let mut iter = args.into_iter();
53
54        while let Some(arg) = iter.next() {
55            if arg.starts_with("--") {
56                let key = arg[2..].to_string();
57                if let Some(eq_pos) = key.find('=') {
58                    let (k, v) = key.split_at(eq_pos);
59                    flags.insert(k.to_string(), v[1..].to_string());
60                } else {
61                    match key.as_str() {
62                        "help" | "daemon" | "disable-auth" | "version" => {
63                            flags.insert(key, "true".into());
64                        }
65                        _ => {
66                            if let Some(val) = iter.next() {
67                                flags.insert(key, val);
68                            } else {
69                                flags.insert(key, "true".into());
70                            }
71                        }
72                    }
73                }
74            } else if arg.starts_with('-') && arg.len() > 1 {
75                for c in arg[1..].chars() {
76                    match c {
77                        'v' => verbosity = verbosity.saturating_add(1),
78                        'h' => {
79                            flags.insert("help".into(), "true".into());
80                        }
81                        'd' => {
82                            flags.insert("daemon".into(), "true".into());
83                        }
84                        _ => {
85                            // Short flag with value: -c /path, -p 8080, -t token
86                            if let Some(val) = iter.next() {
87                                flags.insert(c.to_string(), val);
88                            } else {
89                                flags.insert(c.to_string(), "true".into());
90                            }
91                        }
92                    }
93                }
94            }
95        }
96
97        Args { flags, verbosity }
98    }
99
100    pub fn get(&self, key: &str) -> Option<&str> {
101        self.flags.get(key).map(|s| s.as_str())
102    }
103
104    pub fn has(&self, key: &str) -> bool {
105        self.flags.contains_key(key)
106    }
107}
108
109/// Build CtlConfig from CLI args + environment variables.
110pub fn from_args_and_env(args: &Args) -> CtlConfig {
111    let mut cfg = CtlConfig::default();
112
113    // CLI args take precedence over env vars
114    cfg.host = args
115        .get("host")
116        .or_else(|| args.get("H"))
117        .map(String::from)
118        .or_else(|| std::env::var("RNSCTL_HOST").ok())
119        .unwrap_or(cfg.host);
120
121    cfg.port = args
122        .get("port")
123        .or_else(|| args.get("p"))
124        .and_then(|s| s.parse().ok())
125        .or_else(|| {
126            std::env::var("RNSCTL_HTTP_PORT")
127                .ok()
128                .and_then(|s| s.parse().ok())
129        })
130        .unwrap_or(cfg.port);
131
132    cfg.auth_token = args
133        .get("token")
134        .or_else(|| args.get("t"))
135        .map(String::from)
136        .or_else(|| std::env::var("RNSCTL_AUTH_TOKEN").ok());
137
138    cfg.disable_auth = args.has("disable-auth")
139        || std::env::var("RNSCTL_DISABLE_AUTH")
140            .map(|v| v == "true" || v == "1")
141            .unwrap_or(false);
142
143    cfg.config_path = args
144        .get("config")
145        .or_else(|| args.get("c"))
146        .map(String::from)
147        .or_else(|| std::env::var("RNSCTL_CONFIG_PATH").ok());
148
149    cfg.daemon_mode = args.has("daemon");
150
151    cfg.tls_cert = args.get("tls-cert").map(String::from);
152    cfg.tls_key = args.get("tls-key").map(String::from);
153
154    cfg
155}
156
157pub fn print_help() {
158    println!(
159        "rns-ctl - HTTP/WebSocket control interface for Reticulum
160
161USAGE:
162    rns-ctl [OPTIONS]
163
164OPTIONS:
165    -c, --config PATH       Path to RNS config directory
166    -p, --port PORT         HTTP port (default: 8080, env: RNSCTL_HTTP_PORT)
167    -H, --host HOST         Bind host (default: 127.0.0.1, env: RNSCTL_HOST)
168    -t, --token TOKEN       Auth bearer token (env: RNSCTL_AUTH_TOKEN)
169    -d, --daemon            Connect as client to running rnsd
170        --disable-auth      Disable authentication
171        --tls-cert PATH     TLS certificate file (requires 'tls' feature)
172        --tls-key PATH      TLS private key file (requires 'tls' feature)
173    -v                      Increase verbosity (repeat for more)
174    -h, --help              Show this help
175        --version           Show version"
176    );
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn args(s: &[&str]) -> Args {
184        Args::parse_from(s.iter().map(|s| s.to_string()).collect())
185    }
186
187    #[test]
188    fn parse_basic() {
189        let a = args(&["--port", "9090", "--host", "0.0.0.0", "-vv"]);
190        assert_eq!(a.get("port"), Some("9090"));
191        assert_eq!(a.get("host"), Some("0.0.0.0"));
192        assert_eq!(a.verbosity, 2);
193    }
194
195    #[test]
196    fn parse_short_config() {
197        let a = args(&["-c", "/tmp/rns"]);
198        assert_eq!(a.get("c"), Some("/tmp/rns"));
199    }
200
201    #[test]
202    fn parse_daemon() {
203        let a = args(&["-d"]);
204        assert!(a.has("daemon"));
205    }
206
207    #[test]
208    fn parse_disable_auth() {
209        let a = args(&["--disable-auth"]);
210        assert!(a.has("disable-auth"));
211    }
212
213    #[test]
214    fn parse_help() {
215        let a = args(&["--help"]);
216        assert!(a.has("help"));
217        let a = args(&["-h"]);
218        assert!(a.has("help"));
219    }
220
221    #[test]
222    fn config_from_args() {
223        let a = args(&["--port", "3000", "--host", "0.0.0.0", "--token", "secret", "--daemon"]);
224        let cfg = from_args_and_env(&a);
225        assert_eq!(cfg.port, 3000);
226        assert_eq!(cfg.host, "0.0.0.0");
227        assert_eq!(cfg.auth_token.as_deref(), Some("secret"));
228        assert!(cfg.daemon_mode);
229    }
230}