hoprd/
config.rs

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