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