hoprd/
config.rs

1use std::{
2    collections::HashSet,
3    net::{IpAddr, SocketAddr},
4    str::FromStr,
5    time::Duration,
6};
7
8use hopr_lib::{
9    Address,
10    config::{HoprLibConfig, HostConfig, HostType},
11};
12use hoprd_api::config::{Api, Auth};
13use proc_macro_regex::regex;
14use serde::{Deserialize, Serialize};
15use serde_with::serde_as;
16use tracing::debug;
17use validator::{Validate, ValidationError};
18
19use crate::errors::HoprdError;
20
21pub const DEFAULT_HOST: &str = "0.0.0.0";
22pub const DEFAULT_PORT: u16 = 9091;
23
24// Validate that the path is a valid UTF-8 path.
25//
26// Also used to perform the identity file existence check on the
27// specified path, which is now circumvented but could
28// return in the future workflows of setting up a node.
29fn validate_file_path(_s: &str) -> Result<(), ValidationError> {
30    Ok(())
31
32    // if std::path::Path::new(_s).is_file() {
33    //     Ok(())
34    // } else {
35    //     Err(ValidationError::new(
36    //         "Invalid file path specified, the file does not exist or is not a file",
37    //     ))
38    // }
39}
40
41fn validate_password(s: &str) -> Result<(), ValidationError> {
42    if !s.is_empty() {
43        Ok(())
44    } else {
45        Err(ValidationError::new("No password could be found"))
46    }
47}
48
49regex!(is_private_key "^(0[xX])?[a-fA-F0-9]{128}$");
50
51pub(crate) fn validate_private_key(s: &str) -> Result<(), ValidationError> {
52    if is_private_key(s) {
53        Ok(())
54    } else {
55        Err(ValidationError::new("No valid private key could be found"))
56    }
57}
58
59fn validate_optional_private_key(s: &str) -> Result<(), ValidationError> {
60    validate_private_key(s)
61}
62
63#[derive(Default, Serialize, Deserialize, Validate, Clone, PartialEq)]
64#[serde(deny_unknown_fields)]
65pub struct Identity {
66    #[validate(custom(function = "validate_file_path"))]
67    #[serde(default)]
68    pub file: String,
69    #[validate(custom(function = "validate_password"))]
70    #[serde(default)]
71    pub password: String,
72    #[validate(custom(function = "validate_optional_private_key"))]
73    #[serde(default)]
74    pub private_key: Option<String>,
75}
76
77impl std::fmt::Debug for Identity {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        let obfuscated: String = "<REDACTED>".into();
80
81        f.debug_struct("Identity")
82            .field("file", &self.file)
83            .field("password", &obfuscated)
84            .field("private_key", &obfuscated)
85            .finish()
86    }
87}
88
89#[derive(Debug, Clone, PartialEq, smart_default::SmartDefault, Serialize, Deserialize, Validate)]
90#[serde(deny_unknown_fields)]
91pub struct Db {
92    /// Path to the directory containing the database
93    #[serde(default)]
94    pub data: String,
95    #[serde(default = "just_true")]
96    #[default = true]
97    pub initialize: bool,
98    #[serde(default)]
99    pub force_initialize: bool,
100}
101
102/// The main configuration object of the entire node.
103///
104/// The configuration is composed of individual configuration of corresponding
105/// component configuration objects.
106///
107/// An always up-to-date config YAML example can be found in [example_cfg.yaml](https://github.com/hoprnet/hoprnet/tree/master/hoprd/hoprd/example_cfg.yaml)
108/// which is always in the root of this crate.
109#[derive(Debug, Serialize, Deserialize, Validate, Clone, PartialEq, smart_default::SmartDefault)]
110#[serde(deny_unknown_fields)]
111pub struct HoprdConfig {
112    /// Configuration related to hopr functionality
113    #[validate(nested)]
114    #[serde(default)]
115    pub hopr: HoprLibConfig,
116    /// Configuration regarding the identity of the node
117    #[validate(nested)]
118    #[serde(default)]
119    pub identity: Identity,
120    /// Configuration of the underlying database engine
121    #[validate(nested)]
122    #[serde(default)]
123    pub db: Db,
124    /// Configuration relevant for the API of the node
125    #[validate(nested)]
126    #[serde(default)]
127    pub api: Api,
128    /// Configuration of the Session entry/exit node IP protocol forwarding.
129    #[validate(nested)]
130    #[serde(default)]
131    pub session_ip_forwarding: SessionIpForwardingConfig,
132    /// Blokli provider to connect to.
133    #[validate(url)]
134    pub provider: Option<String>,
135    /// Configuration of underlying node behavior in the form strategies
136    ///
137    /// Strategies represent automatically executable behavior performed by
138    /// the node given pre-configured triggers.
139    #[validate(nested)]
140    #[serde(default = "hopr_strategy::hopr_default_strategies")]
141    #[default(hopr_strategy::hopr_default_strategies())]
142    pub strategy: hopr_strategy::StrategyConfig,
143}
144
145impl From<HoprdConfig> for HoprLibConfig {
146    fn from(val: HoprdConfig) -> HoprLibConfig {
147        val.hopr
148    }
149}
150
151impl HoprdConfig {
152    pub fn from_cli_args(cli_args: crate::cli::CliArgs, skip_validation: bool) -> crate::errors::Result<HoprdConfig> {
153        let mut cfg: HoprdConfig = if let Some(cfg_path) = cli_args.configuration_file_path {
154            debug!(cfg_path, "fetching configuration from file");
155            let yaml_configuration = std::fs::read_to_string(cfg_path.as_str())
156                .map_err(|e| crate::errors::HoprdError::ConfigError(e.to_string()))?;
157            serde_yaml::from_str(&yaml_configuration)
158                .map_err(|e| crate::errors::HoprdError::SerializationError(e.to_string()))?
159        } else {
160            debug!("loading default configuration");
161            HoprdConfig::default()
162        };
163
164        // host
165        if let Some(x) = cli_args.host {
166            cfg.hopr.host = x
167        };
168
169        // hopr.transport
170        if cli_args.test_announce_local_addresses > 0 {
171            cfg.hopr.transport.announce_local_addresses = true;
172        }
173        if cli_args.test_prefer_local_addresses > 0 {
174            cfg.hopr.transport.prefer_local_addresses = true;
175        }
176
177        if let Some(host) = cli_args.default_session_listen_host {
178            cfg.session_ip_forwarding.default_entry_listen_host = match host.address {
179                HostType::IPv4(addr) => IpAddr::from_str(&addr)
180                    .map(|ip| std::net::SocketAddr::new(ip, host.port))
181                    .map_err(|_| HoprdError::ConfigError("invalid default session listen IP address".into())),
182                HostType::Domain(_) => Err(HoprdError::ConfigError("default session listen must be an IP".into())),
183            }?;
184        }
185
186        // db
187        if let Some(data) = cli_args.data {
188            cfg.db.data = data
189        }
190        if cli_args.init > 0 {
191            cfg.db.initialize = true;
192        }
193        if cli_args.force_init > 0 {
194            cfg.db.force_initialize = true;
195        }
196
197        // api
198        if cli_args.api > 0 {
199            cfg.api.enable = true;
200        }
201        if cli_args.disable_api_authentication > 0 && cfg.api.auth != Auth::None {
202            cfg.api.auth = Auth::None;
203        };
204        if let Some(x) = cli_args.api_token {
205            cfg.api.auth = Auth::Token(x);
206        };
207        if let Some(x) = cli_args.api_host {
208            cfg.api.host =
209                HostConfig::from_str(format!("{}:{}", x.as_str(), hoprd_api::config::DEFAULT_API_PORT).as_str())
210                    .map_err(crate::errors::HoprdError::ValidationError)?;
211        }
212        if let Some(x) = cli_args.api_port {
213            cfg.api.host.port = x
214        };
215
216        // probe
217        if let Some(x) = cli_args.probe_recheck_threshold {
218            cfg.hopr.probe.recheck_threshold = std::time::Duration::from_secs(x)
219        };
220
221        // network options
222        if let Some(x) = cli_args.network_quality_threshold {
223            cfg.hopr.network_options.quality_offline_threshold = x
224        };
225
226        // identity
227        if let Some(identity) = cli_args.identity {
228            cfg.identity.file = identity;
229        }
230        if let Some(x) = cli_args.password {
231            cfg.identity.password = x
232        };
233        if let Some(x) = cli_args.private_key {
234            cfg.identity.private_key = Some(x)
235        };
236
237        // chain
238        if cli_args.announce > 0 {
239            cfg.hopr.publish = true;
240        }
241
242        if let Some(x) = cli_args.provider {
243            cfg.provider = Some(x);
244        }
245
246        if let Some(x) = cli_args.safe_address {
247            cfg.hopr.safe_module.safe_address =
248                Address::from_str(&x).map_err(|e| HoprdError::ValidationError(e.to_string()))?
249        };
250        if let Some(x) = cli_args.module_address {
251            cfg.hopr.safe_module.module_address =
252                Address::from_str(&x).map_err(|e| HoprdError::ValidationError(e.to_string()))?
253        };
254
255        // additional updates
256        let home_symbol = '~';
257        if cfg.db.data.starts_with(home_symbol) {
258            cfg.db.data = home::home_dir()
259                .map(|h| h.as_path().display().to_string())
260                .expect("home dir for a user must be specified")
261                + &cfg.db.data[1..];
262        }
263        if cfg.identity.file.starts_with(home_symbol) {
264            cfg.identity.file = home::home_dir()
265                .map(|h| h.as_path().display().to_string())
266                .expect("home dir for a user must be specified")
267                + &cfg.identity.file[1..];
268        }
269
270        if skip_validation {
271            return Ok(cfg);
272        }
273
274        match cfg.validate() {
275            Ok(_) => Ok(cfg),
276            Err(e) => Err(crate::errors::HoprdError::ValidationError(e.to_string())),
277        }
278    }
279
280    pub fn as_redacted(&self) -> Self {
281        let mut ret = self.clone();
282        // redacting sensitive information
283        match ret.api.auth {
284            Auth::None => {}
285            Auth::Token(_) => ret.api.auth = Auth::Token("<REDACTED>".to_owned()),
286        }
287
288        if ret.identity.private_key.is_some() {
289            ret.identity.private_key = Some("<REDACTED>".to_owned());
290        }
291
292        "<REDACTED>".clone_into(&mut ret.identity.password);
293
294        ret
295    }
296
297    pub fn as_redacted_string(&self) -> crate::errors::Result<String> {
298        let redacted_cfg = self.as_redacted();
299        serde_json::to_string(&redacted_cfg).map_err(|e| crate::errors::HoprdError::SerializationError(e.to_string()))
300    }
301}
302
303fn default_target_retry_delay() -> Duration {
304    Duration::from_secs(2)
305}
306
307fn default_entry_listen_host() -> SocketAddr {
308    "127.0.0.1:0".parse().unwrap()
309}
310
311fn default_max_tcp_target_retries() -> u32 {
312    10
313}
314
315fn just_true() -> bool {
316    true
317}
318
319/// Configuration of the Exit node (see [`HoprServerIpForwardingReactor`](crate::exit::HoprServerIpForwardingReactor))
320/// and the Entry node.
321#[serde_as]
322#[derive(
323    Clone, Debug, Eq, PartialEq, smart_default::SmartDefault, serde::Deserialize, serde::Serialize, validator::Validate,
324)]
325pub struct SessionIpForwardingConfig {
326    /// Controls whether allowlisting should be done via `target_allow_list`.
327    /// If set to `false`, the node will act as an Exit node for any target.
328    ///
329    /// Defaults to `true`.
330    #[serde(default = "just_true")]
331    #[default(true)]
332    pub use_target_allow_list: bool,
333
334    /// Enforces only the given target addresses (after DNS resolution).
335    ///
336    /// This is used only if `use_target_allow_list` is set to `true`.
337    /// If left empty (and `use_target_allow_list` is `true`), the node will not act as an Exit node.
338    ///
339    /// Defaults to empty.
340    #[serde(default)]
341    #[serde_as(as = "HashSet<serde_with::DisplayFromStr>")]
342    pub target_allow_list: HashSet<SocketAddr>,
343
344    /// Delay between retries in seconds to reach a TCP target.
345    ///
346    /// Defaults to 2 seconds.
347    #[serde(default = "default_target_retry_delay")]
348    #[default(default_target_retry_delay())]
349    #[serde_as(as = "serde_with::DurationSeconds<u64>")]
350    pub tcp_target_retry_delay: Duration,
351
352    /// Maximum number of retries to reach a TCP target before giving up.
353    ///
354    /// Default is 10.
355    #[serde(default = "default_max_tcp_target_retries")]
356    #[default(default_max_tcp_target_retries())]
357    #[validate(range(min = 1))]
358    pub max_tcp_target_retries: u32,
359
360    /// Specifies the default `listen_host` for Session listening sockets
361    /// at an Entry node.
362    #[serde(default = "default_entry_listen_host")]
363    #[default(default_entry_listen_host())]
364    #[serde_as(as = "serde_with::DisplayFromStr")]
365    pub default_entry_listen_host: SocketAddr,
366}
367
368#[cfg(test)]
369mod tests {
370    use std::io::{Read, Write};
371
372    use anyhow::Context;
373    use clap::{Args, Command, FromArgMatches};
374    use tempfile::NamedTempFile;
375
376    use super::*;
377
378    pub fn example_cfg() -> anyhow::Result<HoprdConfig> {
379        let safe_module = hopr_lib::config::SafeModule {
380            safe_transaction_service_provider: "https:://provider.com/".to_owned(),
381            safe_address: Address::from_str("0x0000000000000000000000000000000000000000")?,
382            module_address: Address::from_str("0x0000000000000000000000000000000000000000")?,
383        };
384
385        let identity = Identity {
386            file: "path/to/identity.file".to_string(),
387            password: "change_me".to_owned(),
388            private_key: None,
389        };
390
391        let host = HostConfig {
392            address: HostType::IPv4("1.2.3.4".into()),
393            port: 9091,
394        };
395
396        Ok(HoprdConfig {
397            hopr: HoprLibConfig {
398                host,
399                safe_module,
400                ..HoprLibConfig::default()
401            },
402            db: Db {
403                data: "/app/db".to_owned(),
404                ..Default::default()
405            },
406            identity,
407            ..HoprdConfig::default()
408        })
409    }
410
411    #[test]
412    fn test_config_should_be_serializable_into_string() -> Result<(), Box<dyn std::error::Error>> {
413        let cfg = example_cfg()?;
414
415        let from_yaml: HoprdConfig = serde_yaml::from_str(include_str!("../example_cfg.yaml"))?;
416
417        assert_eq!(cfg, from_yaml);
418
419        Ok(())
420    }
421
422    #[test]
423    fn test_config_should_be_deserializable_from_a_string_in_a_file() -> Result<(), Box<dyn std::error::Error>> {
424        let mut config_file = NamedTempFile::new()?;
425        let mut prepared_config_file = config_file.reopen()?;
426
427        let cfg = example_cfg()?;
428        let yaml = serde_yaml::to_string(&cfg)?;
429        config_file.write_all(yaml.as_bytes())?;
430
431        let mut buf = String::new();
432        prepared_config_file.read_to_string(&mut buf)?;
433        let deserialized_cfg: HoprdConfig = serde_yaml::from_str(&buf)?;
434
435        assert_eq!(deserialized_cfg, cfg);
436
437        Ok(())
438    }
439
440    /// TODO: This test attempts to deserialize the data structure incorrectly in the native build
441    /// (`confirmations`` are an extra field), as well as misses the native implementation for the
442    /// version satisfies check
443    #[test]
444    #[ignore]
445    fn test_config_is_extractable_from_the_cli_arguments() -> anyhow::Result<()> {
446        let pwnd = "rpc://pawned!";
447
448        let mut config_file = NamedTempFile::new()?;
449
450        let mut cfg = example_cfg()?;
451        cfg.provider = Some(pwnd.to_owned());
452
453        let yaml = serde_yaml::to_string(&cfg)?;
454        config_file.write_all(yaml.as_bytes())?;
455        let cfg_file_path = config_file
456            .path()
457            .to_str()
458            .context("file path should have a string representation")?
459            .to_string();
460
461        let cli_args = vec!["hoprd", "--configurationFilePath", cfg_file_path.as_str()];
462
463        let mut cmd = Command::new("hoprd").version("0.0.0");
464        cmd = crate::cli::CliArgs::augment_args(cmd);
465        let derived_matches = cmd.try_get_matches_from(cli_args)?;
466        let args = crate::cli::CliArgs::from_arg_matches(&derived_matches)?;
467
468        // skipping validation
469        let cfg = HoprdConfig::from_cli_args(args, true);
470
471        assert!(cfg.is_ok());
472
473        let cfg = cfg?;
474
475        assert_eq!(cfg.provider, Some(pwnd.to_owned()));
476
477        Ok(())
478    }
479}