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
23fn validate_file_path(_s: &str) -> Result<(), ValidationError> {
29 Ok(())
30
31 }
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#[derive(Debug, Default, Serialize, Deserialize, Validate, Clone, PartialEq)]
96#[serde(deny_unknown_fields)]
97pub struct HoprdConfig {
98 #[validate(nested)]
100 #[serde(default)]
101 pub hopr: HoprLibConfig,
102 #[validate(nested)]
104 #[serde(default)]
105 pub identity: Identity,
106 #[validate(nested)]
108 #[serde(default)]
109 pub api: Api,
110 #[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 if let Some(x) = cli_args.host {
137 cfg.hopr.host = x
138 };
139
140 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 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 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 if let Some(x) = cli_args.probe_recheck_threshold {
189 cfg.hopr.probe.recheck_threshold = std::time::Duration::from_secs(x)
190 };
191
192 if let Some(x) = cli_args.network_quality_threshold {
194 cfg.hopr.network_options.quality_offline_threshold = x
195 };
196
197 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 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 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 for (_, n) in cfg.hopr.chain.protocols.networks.iter_mut() {
236 n.max_block_range = x;
237 }
238 }
239
240 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 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 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 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#[serde_as]
356#[derive(
357 Clone, Debug, Eq, PartialEq, smart_default::SmartDefault, serde::Deserialize, serde::Serialize, validator::Validate,
358)]
359pub struct SessionIpForwardingConfig {
360 #[serde(default = "just_true")]
365 #[default(true)]
366 pub use_target_allow_list: bool,
367
368 #[serde(default)]
375 #[serde_as(as = "HashSet<serde_with::DisplayFromStr>")]
376 pub target_allow_list: HashSet<SocketAddr>,
377
378 #[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 #[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 #[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 #[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 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}