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 --enable*/--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        cfg.hopr.chain.enable_logs_snapshot = cli_args.enable_logs_snapshot != 0;
248
249        if let Some(x) = cli_args.logs_snapshot_url {
250            cfg.hopr.chain.logs_snapshot_url = Some(x);
251        }
252
253        // safe module
254        if let Some(x) = cli_args.safe_transaction_service_provider {
255            cfg.hopr.safe_module.safe_transaction_service_provider = x
256        };
257        if let Some(x) = cli_args.safe_address {
258            cfg.hopr.safe_module.safe_address =
259                Address::from_str(&x).map_err(|e| HoprdError::ValidationError(e.to_string()))?
260        };
261        if let Some(x) = cli_args.module_address {
262            cfg.hopr.safe_module.module_address =
263                Address::from_str(&x).map_err(|e| HoprdError::ValidationError(e.to_string()))?
264        };
265
266        // additional updates
267        let home_symbol = '~';
268        if cfg.hopr.db.data.starts_with(home_symbol) {
269            cfg.hopr.db.data = home::home_dir()
270                .map(|h| h.as_path().display().to_string())
271                .expect("home dir for a user must be specified")
272                + &cfg.hopr.db.data[1..];
273        }
274        if cfg.identity.file.starts_with(home_symbol) {
275            cfg.identity.file = home::home_dir()
276                .map(|h| h.as_path().display().to_string())
277                .expect("home dir for a user must be specified")
278                + &cfg.identity.file[1..];
279        }
280
281        if skip_validation {
282            Ok(cfg)
283        } else {
284            if !cfg
285                .hopr
286                .chain
287                .protocols
288                .supported_networks(hopr_lib::constants::APP_VERSION_COERCED)
289                .iter()
290                .any(|network| network == &cfg.hopr.chain.network)
291            {
292                return Err(crate::errors::HoprdError::ValidationError(format!(
293                    "The specified network '{}' is not listed as supported ({:?})",
294                    cfg.hopr.chain.network,
295                    cfg.hopr
296                        .chain
297                        .protocols
298                        .supported_networks(hopr_lib::constants::APP_VERSION_COERCED)
299                )));
300            }
301
302            match cfg.validate() {
303                Ok(_) => Ok(cfg),
304                Err(e) => Err(crate::errors::HoprdError::ValidationError(e.to_string())),
305            }
306        }
307    }
308
309    pub fn as_redacted(&self) -> Self {
310        let mut ret = self.clone();
311        // redacting sensitive information
312        match ret.api.auth {
313            Auth::None => {}
314            Auth::Token(_) => ret.api.auth = Auth::Token("<REDACTED>".to_owned()),
315        }
316
317        if ret.identity.private_key.is_some() {
318            ret.identity.private_key = Some("<REDACTED>".to_owned());
319        }
320
321        "<REDACTED>".clone_into(&mut ret.identity.password);
322
323        ret
324    }
325
326    pub fn as_redacted_string(&self) -> crate::errors::Result<String> {
327        let redacted_cfg = self.as_redacted();
328        serde_json::to_string(&redacted_cfg).map_err(|e| crate::errors::HoprdError::SerializationError(e.to_string()))
329    }
330}
331
332fn default_target_retry_delay() -> Duration {
333    Duration::from_secs(2)
334}
335
336fn default_entry_listen_host() -> SocketAddr {
337    "127.0.0.1:0".parse().unwrap()
338}
339
340fn default_max_tcp_target_retries() -> u32 {
341    10
342}
343
344fn just_true() -> bool {
345    true
346}
347
348/// Configuration of the Exit node (see [`HoprServerIpForwardingReactor`](crate::exit::HoprServerIpForwardingReactor))
349/// and the Entry node.
350#[serde_as]
351#[derive(
352    Clone, Debug, Eq, PartialEq, smart_default::SmartDefault, serde::Deserialize, serde::Serialize, validator::Validate,
353)]
354pub struct SessionIpForwardingConfig {
355    /// Controls whether allowlisting should be done via `target_allow_list`.
356    /// If set to `false`, the node will act as an Exit node for any target.
357    ///
358    /// Defaults to `true`.
359    #[serde(default = "just_true")]
360    #[default(true)]
361    pub use_target_allow_list: bool,
362
363    /// Enforces only the given target addresses (after DNS resolution).
364    ///
365    /// This is used only if `use_target_allow_list` is set to `true`.
366    /// If left empty (and `use_target_allow_list` is `true`), the node will not act as an Exit node.
367    ///
368    /// Defaults to empty.
369    #[serde(default)]
370    #[serde_as(as = "HashSet<serde_with::DisplayFromStr>")]
371    pub target_allow_list: HashSet<SocketAddr>,
372
373    /// Delay between retries in seconds to reach a TCP target.
374    ///
375    /// Defaults to 2 seconds.
376    #[serde(default = "default_target_retry_delay")]
377    #[default(default_target_retry_delay())]
378    #[serde_as(as = "serde_with::DurationSeconds<u64>")]
379    pub tcp_target_retry_delay: Duration,
380
381    /// Maximum number of retries to reach a TCP target before giving up.
382    ///
383    /// Default is 10.
384    #[serde(default = "default_max_tcp_target_retries")]
385    #[default(default_max_tcp_target_retries())]
386    #[validate(range(min = 1))]
387    pub max_tcp_target_retries: u32,
388
389    /// Specifies the default `listen_host` for Session listening sockets
390    /// at an Entry node.
391    #[serde(default = "default_entry_listen_host")]
392    #[default(default_entry_listen_host())]
393    #[serde_as(as = "serde_with::DisplayFromStr")]
394    pub default_entry_listen_host: SocketAddr,
395}
396
397#[cfg(test)]
398mod tests {
399    use std::io::{Read, Write};
400
401    use anyhow::Context;
402    use clap::{Args, Command, FromArgMatches};
403    use hopr_lib::HostType;
404    use tempfile::NamedTempFile;
405
406    use super::*;
407
408    pub fn example_cfg() -> anyhow::Result<HoprdConfig> {
409        let chain = hopr_lib::config::Chain {
410            protocols: hopr_lib::ProtocolsConfig::from_str(
411                r#"
412                    {
413                        "networks": {
414                          "anvil-localhost": {
415                            "chain": "anvil",
416                            "environment_type": "local",
417                            "version_range": "*",
418                            "indexer_start_block_number": 5,
419                            "addresses": {
420                              "network_registry": "0x3Aa5ebB10DC797CAC828524e59A333d0A371443c",
421                              "network_registry_proxy": "0x68B1D87F95878fE05B998F19b66F4baba5De1aed",
422                              "channels": "0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE",
423                              "token": "0x9A676e781A523b5d0C0e43731313A708CB607508",
424                              "module_implementation": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0",
425                              "node_safe_registry": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82",
426                              "ticket_price_oracle": "0x7a2088a1bFc9d81c55368AE168C2C02570cB814F",
427                              "winning_probability_oracle": "0x09635F643e140090A9A8Dcd712eD6285858ceBef",
428                              "announcements": "0xc5a5C42992dECbae36851359345FE25997F5C42d",
429                              "node_stake_v2_factory": "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e"
430                            },
431                            "confirmations": 2,
432                            "tags": [],
433                            "tx_polling_interval": 1000,
434                            "max_block_range": 200
435                          }
436                        },
437                        "chains": {
438                          "anvil": {
439                            "description": "Local Ethereum node, akin to Ganache, Hardhat chain",
440                            "chain_id": 31337,
441                            "live": false,
442                            "max_fee_per_gas": "1 gwei",
443                            "max_priority_fee_per_gas": "0.2 gwei",
444                            "default_provider": "http://127.0.0.1:8545/",
445                            "native_token_name": "ETH",
446                            "hopr_token_name": "wxHOPR",
447                            "block_time": 5000,
448                            "max_rpc_requests_per_sec": 100,
449                            "tags": [],
450                            "etherscan_api_url": null
451                          }
452                        }
453                      }
454                    "#,
455            )
456            .map_err(|e| anyhow::anyhow!(e))?,
457            ..hopr_lib::config::Chain::default()
458        };
459
460        let db = hopr_lib::config::Db {
461            data: "/app/db".to_owned(),
462            ..hopr_lib::config::Db::default()
463        };
464
465        let safe_module = hopr_lib::config::SafeModule {
466            safe_transaction_service_provider: "https:://provider.com/".to_owned(),
467            safe_address: Address::from_str("0x0000000000000000000000000000000000000000")?,
468            module_address: Address::from_str("0x0000000000000000000000000000000000000000")?,
469        };
470
471        let identity = Identity {
472            file: "path/to/identity.file".to_string(),
473            password: "change_me".to_owned(),
474            private_key: None,
475        };
476
477        let host = HostConfig {
478            address: HostType::IPv4("1.2.3.4".into()),
479            port: 9091,
480        };
481
482        Ok(HoprdConfig {
483            hopr: HoprLibConfig {
484                host,
485                db,
486                chain,
487                safe_module,
488                ..HoprLibConfig::default()
489            },
490            identity,
491            ..HoprdConfig::default()
492        })
493    }
494
495    #[test]
496    fn test_config_should_be_serializable_into_string() -> Result<(), Box<dyn std::error::Error>> {
497        let cfg = example_cfg()?;
498
499        let from_yaml: HoprdConfig = serde_yaml::from_str(include_str!("../example_cfg.yaml"))?;
500
501        assert_eq!(cfg, from_yaml);
502
503        Ok(())
504    }
505
506    #[test]
507    fn test_config_should_be_deserializable_from_a_string_in_a_file() -> Result<(), Box<dyn std::error::Error>> {
508        let mut config_file = NamedTempFile::new()?;
509        let mut prepared_config_file = config_file.reopen()?;
510
511        let cfg = example_cfg()?;
512        let yaml = serde_yaml::to_string(&cfg)?;
513        config_file.write_all(yaml.as_bytes())?;
514
515        let mut buf = String::new();
516        prepared_config_file.read_to_string(&mut buf)?;
517        let deserialized_cfg: HoprdConfig = serde_yaml::from_str(&buf)?;
518
519        assert_eq!(deserialized_cfg, cfg);
520
521        Ok(())
522    }
523
524    /// TODO: This test attempts to deserialize the data structure incorrectly in the native build
525    /// (`confirmations`` are an extra field), as well as misses the native implementation for the
526    /// version satisfies check
527    #[test]
528    #[ignore]
529    fn test_config_is_extractable_from_the_cli_arguments() -> anyhow::Result<()> {
530        let pwnd = "rpc://pawned!";
531
532        let mut config_file = NamedTempFile::new()?;
533
534        let mut cfg = example_cfg()?;
535        cfg.hopr.chain.provider = Some(pwnd.to_owned());
536
537        let yaml = serde_yaml::to_string(&cfg)?;
538        config_file.write_all(yaml.as_bytes())?;
539        let cfg_file_path = config_file
540            .path()
541            .to_str()
542            .context("file path should have a string representation")?
543            .to_string();
544
545        let cli_args = vec!["hoprd", "--configurationFilePath", cfg_file_path.as_str()];
546
547        let mut cmd = Command::new("hoprd").version("0.0.0");
548        cmd = crate::cli::CliArgs::augment_args(cmd);
549        let derived_matches = cmd.try_get_matches_from(cli_args)?;
550        let args = crate::cli::CliArgs::from_arg_matches(&derived_matches)?;
551
552        // skipping validation
553        let cfg = HoprdConfig::from_cli_args(args, true);
554
555        assert!(cfg.is_ok());
556
557        let cfg = cfg?;
558
559        assert_eq!(cfg.hopr.chain.provider, Some(pwnd.to_owned()));
560
561        Ok(())
562    }
563}