diff --git a/CHANGELOG.md b/CHANGELOG.md index c32730074b..57d578c6e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## {inc-release} **Features:** +- `adex-cli` command line utility was introduced that supplies commands: `init`, `start`, `stop`, `status` [#1729](https://github.com/KomodoPlatform/atomicDEX-API/pull/1729) **Enhancements/Fixes:** - CI/CD workflow logics are improved [#1736](https://github.com/KomodoPlatform/atomicDEX-API/pull/1736) diff --git a/Cargo.lock b/Cargo.lock index de68861053..b4a274f4b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1181,6 +1181,7 @@ dependencies = [ "cfg-if 1.0.0", "chrono", "crossbeam 0.8.2", + "derive_more", "findshlibs", "fnv", "futures 0.1.29", @@ -1204,6 +1205,7 @@ dependencies = [ "parking_lot_core 0.6.2", "primitive-types", "rand 0.7.3", + "regex", "ser_error", "ser_error_derive", "serde", diff --git a/Cargo.toml b/Cargo.toml index 421bdca2c6..388116abd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,10 @@ members = [ "mm2src/trezor", ] +exclude = [ + "mm2src/adex_cli" +] + # https://doc.rust-lang.org/beta/cargo/reference/features.html#feature-resolver-version-2 resolver = "2" diff --git a/README.md b/README.md index 010ba3094c..40f82f6e81 100755 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The AtomicDEX API core is open-source [atomic-swap](https://komodoplatform.com/en/academy/atomic-swaps/) software for seamless, decentralised, peer to peer trading between almost every blockchain asset in existence. This software works with propagation of orderbooks and swap states through the [libp2p](https://libp2p.io/) protocol and uses [Hash Time Lock Contracts (HTLCs)](https://en.bitcoinwiki.org/wiki/Hashed_Timelock_Contracts) for ensuring that the two parties in a swap either mutually complete a trade, or funds return to thier original owner. -There is no 3rd party intermediatary, no proxy tokens, and at all times users remain in sole possession of their private keys. +There is no 3rd party intermediary, no proxy tokens, and at all times users remain in sole possession of their private keys. A [well documented API](https://developers.komodoplatform.com/basic-docs/atomicdex/introduction-to-atomicdex.html) offers simple access to the underlying services using simple language agnostic JSON structured methods and parameters such that users can communicate with the core in a variety of methods such as [curl](https://developers.komodoplatform.com/basic-docs/atomicdex-api-legacy/buy.html) in CLI, or fully functioning [desktop and mobile applications](https://atomicdex.io/) like [AtomicDEX Desktop](https://github.com/KomodoPlatform/atomicDEX-Desktop). @@ -115,6 +115,7 @@ For example: The coins file contains information about the coins and tokens you want to trade. A regularly updated version is maintained in the [Komodo Platform coins repository](https://github.com/KomodoPlatform/coins/blob/master/coins). Pull Requests to add any coins not yet included are welcome. +To facilitate interoperability with the `mm2` service, there is the `adex-cli` command line utility. It provides a questionnaire initialization mode to set up the configuration and obtain the proper coin set through the internet. It can also be used to start or stop the service. ## Usage diff --git a/mm2src/adex_cli/Cargo.toml b/mm2src/adex_cli/Cargo.toml new file mode 100644 index 0000000000..2f1abdc0a0 --- /dev/null +++ b/mm2src/adex_cli/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "adex-cli" +version = "0.1.0" +edition = "2021" +authors = ["Rozhkov Dmitrii "] +description = "Provides a CLI interface and facilitates interoperating to komodo atomic dex through the mm2 service" +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +clap = "2.33.3" +common = { path = "../common" } +derive_more = "0.99" +env_logger = "0.7.1" +gstuff = { version = "=0.7.4" , features = [ "nightly" ]} +inquire = "0.6" +log = "0.4" +mm2_net = { path = "../mm2_net" } +passwords = "3.1" +serde = "1.0" +serde_json = { version = "1", features = ["preserve_order", "raw_value"] } +sysinfo = "0.28" +tiny-bip39 = "0.8.0" +tokio = { version = "1.20", features = [ "macros" ] } + +[target.'cfg(windows)'.dependencies] +winapi = { version = "0.3.3", features = ["processthreadsapi", "winnt"] } diff --git a/mm2src/adex_cli/src/cli.rs b/mm2src/adex_cli/src/cli.rs new file mode 100644 index 0000000000..fdbf65161d --- /dev/null +++ b/mm2src/adex_cli/src/cli.rs @@ -0,0 +1,113 @@ +use clap::{App, Arg, SubCommand}; +use log::error; +use std::env; + +use crate::scenarios::{get_status, init, start_process, stop_process}; + +enum Command { + Init { + mm_coins_path: String, + mm_conf_path: String, + }, + Start { + mm_conf_path: Option, + mm_coins_path: Option, + mm_log: Option, + }, + Stop, + Status, +} + +pub fn process_cli() { + let mut app = App::new(env!("CARGO_PKG_NAME")) + .version(env!("CARGO_PKG_VERSION")) + .author(env!("CARGO_PKG_AUTHORS")) + .about(env!("CARGO_PKG_DESCRIPTION")) + .subcommand( + SubCommand::with_name("init") + .about("Initialize predefined mm2 coin set and configuration") + .arg( + Arg::with_name("mm-coins-path") + .long("mm-coins-path") + .value_name("FILE") + .help("coin set file path") + .default_value("coins"), + ) + .arg( + Arg::with_name("mm-conf-path") + .long("mm-conf-path") + .value_name("FILE") + .help("mm2 configuration file path") + .default_value("MM2.json"), + ), + ) + .subcommand( + SubCommand::with_name("start") + .about("Start mm2 service") + .arg( + Arg::with_name("mm-conf-path") + .long("mm-conf-path") + .value_name("FILE") + .help("mm2 configuration file path"), + ) + .arg( + Arg::with_name("mm-coins-path") + .long("mm-coins-path") + .value_name("FILE") + .help("coin set file path"), + ) + .arg( + Arg::with_name("mm-log") + .long("mm-log") + .value_name("FILE") + .help("log file path"), + ), + ) + .subcommand(SubCommand::with_name("stop").about("Stop mm2 instance")) + .subcommand(SubCommand::with_name("status").about("Get mm2 running status")); + + let matches = app.clone().get_matches(); + + let command = match matches.subcommand() { + ("init", Some(init_matches)) => { + let mm_coins_path = init_matches.value_of("mm-coins-path").unwrap_or("coins").to_owned(); + let mm_conf_path = init_matches.value_of("mm-conf-path").unwrap_or("MM2.json").to_owned(); + Command::Init { + mm_coins_path, + mm_conf_path, + } + }, + ("start", Some(start_matches)) => { + let mm_conf_path = start_matches.value_of("mm-conf-path").map(|s| s.to_owned()); + let mm_coins_path = start_matches.value_of("mm-coins-path").map(|s| s.to_owned()); + let mm_log = start_matches.value_of("mm-log").map(|s| s.to_owned()); + Command::Start { + mm_conf_path, + mm_coins_path, + mm_log, + } + }, + ("stop", _) => Command::Stop, + ("status", _) => Command::Status, + _ => { + let _ = app + .print_long_help() + .map_err(|error| error!("Failed to print_long_help: {error}")); + return; + }, + }; + + match command { + Command::Init { + mm_coins_path: coins_file, + mm_conf_path: mm2_cfg_file, + } => init(&mm2_cfg_file, &coins_file), + Command::Start { + mm_conf_path: mm2_cfg_file, + mm_coins_path: coins_file, + mm_log: log_file, + } => start_process(&mm2_cfg_file, &coins_file, &log_file), + Command::Stop => stop_process(), + Command::Status => get_status(), + } +} diff --git a/mm2src/adex_cli/src/log.rs b/mm2src/adex_cli/src/log.rs new file mode 100644 index 0000000000..60ffb13180 --- /dev/null +++ b/mm2src/adex_cli/src/log.rs @@ -0,0 +1,13 @@ +use log::LevelFilter; +use std::io::Write; + +pub fn init_logging() { + let mut builder = env_logger::builder(); + let level = std::env::var("RUST_LOG") + .map(|s| s.parse().expect("Failed to parse RUST_LOG")) + .unwrap_or(LevelFilter::Info); + builder + .filter_level(level) + .format(|buf, record| writeln!(buf, "{}", record.args())); + builder.init(); +} diff --git a/mm2src/adex_cli/src/main.rs b/mm2src/adex_cli/src/main.rs new file mode 100644 index 0000000000..eb80a3b868 --- /dev/null +++ b/mm2src/adex_cli/src/main.rs @@ -0,0 +1,12 @@ +#[cfg(not(target_arch = "wasm32"))] mod cli; +#[cfg(not(target_arch = "wasm32"))] mod log; +#[cfg(not(target_arch = "wasm32"))] mod scenarios; + +#[cfg(target_arch = "wasm32")] +fn main() {} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + log::init_logging(); + cli::process_cli(); +} diff --git a/mm2src/adex_cli/src/scenarios/helpers.rs b/mm2src/adex_cli/src/scenarios/helpers.rs new file mode 100644 index 0000000000..2282bf4b36 --- /dev/null +++ b/mm2src/adex_cli/src/scenarios/helpers.rs @@ -0,0 +1,34 @@ +use common::log::error; +use serde::Serialize; +use std::fs::OpenOptions; +use std::io::Write; +use std::ops::Deref; + +pub fn rewrite_data_file(data: T, file: &str) -> Result<(), ()> +where + T: Deref, +{ + let mut writer = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(file) + .map_err(|error| { + error!("Failed to open {file}: {error}"); + })?; + + writer.write(&data).map_err(|error| { + error!("Failed to write data into {file}: {error}"); + })?; + Ok(()) +} + +pub fn rewrite_json_file(value: &T, file: &str) -> Result<(), ()> +where + T: Serialize, +{ + let data = serde_json::to_vec_pretty(value).map_err(|error| { + error!("Failed to serialize data {error}"); + })?; + rewrite_data_file(data, file) +} diff --git a/mm2src/adex_cli/src/scenarios/init_coins.rs b/mm2src/adex_cli/src/scenarios/init_coins.rs new file mode 100644 index 0000000000..fefcdd7c66 --- /dev/null +++ b/mm2src/adex_cli/src/scenarios/init_coins.rs @@ -0,0 +1,45 @@ +use common::log::{error, info}; +use derive_more::Display; +use mm2_net::transport::slurp_url; + +use super::helpers::rewrite_data_file; + +#[derive(Clone, Copy, Debug, Display)] +pub enum CoinSet { + Empty, + Full, +} + +#[tokio::main(flavor = "current_thread")] +pub async fn init_coins(coins_file: &str) -> Result<(), ()> { + const FULL_COIN_SET_ADDRESS: &str = "https://raw.githubusercontent.com/KomodoPlatform/coins/master/coins"; + const EMPTY_COIN_SET_DATA: &[u8] = b"[]\n"; + let coin_set = inquire_coin_set(coins_file)?; + info!("Start getting mm2 coins"); + let coins_data = match coin_set { + CoinSet::Empty => Vec::::from(EMPTY_COIN_SET_DATA), + CoinSet::Full => { + info!("Getting coin set from: {FULL_COIN_SET_ADDRESS}"); + let (_status_code, _headers, data) = slurp_url(FULL_COIN_SET_ADDRESS).await.map_err(|error| { + error!("Failed to get coin set from: {FULL_COIN_SET_ADDRESS}, error: {error}"); + })?; + data + }, + }; + + rewrite_data_file(coins_data, coins_file)?; + info!("Got coins data, written into: {coins_file}"); + Ok(()) +} + +fn inquire_coin_set(coins_file: &str) -> Result { + inquire::Select::new( + format!("Select one of predefined coin sets to save into: {coins_file}").as_str(), + vec![CoinSet::Empty, CoinSet::Full], + ) + .with_help_message("Information about the currencies: their ticker symbols, names, ports, addresses, etc.") + .prompt() + .map_err(|error| { + error!("Failed to select coin_set: {error}"); + }) +} diff --git a/mm2src/adex_cli/src/scenarios/init_mm2_cfg.rs b/mm2src/adex_cli/src/scenarios/init_mm2_cfg.rs new file mode 100644 index 0000000000..551cf21598 --- /dev/null +++ b/mm2src/adex_cli/src/scenarios/init_mm2_cfg.rs @@ -0,0 +1,331 @@ +use bip39::{Language, Mnemonic, MnemonicType}; +use inquire::{validator::Validation, Confirm, CustomType, CustomUserError, Text}; +use passwords::PasswordGenerator; +use serde::Serialize; +use std::net::Ipv4Addr; +use std::ops::Not; +use std::path::Path; + +use super::helpers; +use super::inquire_extentions::{InquireOption, DEFAULT_DEFAULT_OPTION_BOOL_FORMATTER, DEFAULT_OPTION_BOOL_FORMATTER, + OPTION_BOOL_PARSER}; +use common::log::{error, info}; +use common::password_policy; + +const DEFAULT_NET_ID: u16 = 7777; +const DEFAULT_GID: &str = "adex-cli"; +const DEFAULT_OPTION_PLACEHOLDER: &str = "Tap enter to skip"; +const RPC_PORT_MIN: u16 = 1024; +const RPC_PORT_MAX: u16 = 49151; + +pub fn init_mm2_cfg(cfg_file: &str) -> Result<(), ()> { + let mut mm2_cfg = Mm2Cfg::new(); + info!("Start collecting mm2_cfg into: {cfg_file}"); + mm2_cfg.inquire()?; + helpers::rewrite_json_file(&mm2_cfg, cfg_file)?; + info!("mm2_cfg has been writen into: {cfg_file}"); + + Ok(()) +} + +#[derive(Serialize)] +pub struct Mm2Cfg { + pub gui: Option, + pub netid: Option, + pub rpc_password: Option, + #[serde(rename = "passphrase", skip_serializing_if = "Option::is_none")] + pub seed_phrase: Option, + pub allow_weak_password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dbdir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rpcip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rpcport: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rpc_local_only: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub i_am_seed: Option, + #[serde(skip_serializing_if = "Vec::::is_empty")] + pub seednodes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub hd_account_id: Option, +} + +impl Mm2Cfg { + pub fn new() -> Mm2Cfg { + Mm2Cfg { + gui: None, + netid: None, + rpc_password: None, + seed_phrase: None, + allow_weak_password: None, + dbdir: None, + rpcip: None, + rpcport: None, + rpc_local_only: None, + i_am_seed: None, + seednodes: Vec::::new(), + hd_account_id: None, + } + } + + fn inquire(&mut self) -> Result<(), ()> { + self.inquire_gui()?; + self.inquire_net_id()?; + self.inquire_seed_phrase()?; + self.inquire_allow_weak_password()?; + self.inquire_rpc_password()?; + self.inquire_dbdir()?; + self.inquire_rpcip()?; + self.inquire_rpcport()?; + self.inquire_rpc_local_only()?; + self.inquire_i_am_a_seed()?; + self.inquire_seednodes()?; + self.inquire_hd_account_id()?; + Ok(()) + } + + #[inline] + fn inquire_dbdir(&mut self) -> Result<(), ()> { + let is_reachable_dir = |dbdir: &InquireOption| -> Result { + match dbdir { + InquireOption::None => Ok(Validation::Valid), + InquireOption::Some(dbdir) => { + let path = Path::new(dbdir); + if path.is_dir().not() { + return Ok(Validation::Invalid( + format!("\"{dbdir}\" - is not a directory or does not exist").into(), + )); + } + Ok(Validation::Valid) + }, + } + }; + + self.dbdir = CustomType::>::new("What is dbdir") + .with_placeholder(DEFAULT_OPTION_PLACEHOLDER) + .with_help_message("AtomicDEX API database path. Optional, defaults to a subfolder named DB in the path of your mm2 binary") + .with_validator(is_reachable_dir) + .prompt() + .map_err(|error| { + error!("Failed to get dbdir: {error}"); + })?.into(); + + Ok(()) + } + + #[inline] + fn inquire_gui(&mut self) -> Result<(), ()> { + self.gui = Some(DEFAULT_GID.into()); + info!("> gui is set by default: {DEFAULT_GID}"); + Ok(()) + } + + #[inline] + fn inquire_net_id(&mut self) -> Result<(), ()> { + self.netid = CustomType::::new("What is the network `mm2` is going to be a part, netid:") + .with_default(DEFAULT_NET_ID) + .with_help_message(r#"Network ID number, telling the AtomicDEX API which network to join. 7777 is the current main network, though alternative netids can be used for testing or "private" trades"#) + .with_placeholder(format!("{DEFAULT_NET_ID}").as_str()) + .prompt() + .map_err(|error| { + error!("Failed to get netid: {error}"); + })?.into(); + Ok(()) + } + + #[inline] + fn inquire_seed_phrase(&mut self) -> Result<(), ()> { + let mnemonic = Mnemonic::new(MnemonicType::Words12, Language::English); + let default_password: &str = mnemonic.phrase(); + self.seed_phrase = Text::new("What is the seed phrase:") + .with_default(default_password) + .with_validator(|phrase: &str| { + if phrase == "none" { + return Ok(Validation::Valid); + } + match Mnemonic::validate(phrase, Language::English) { + Ok(_) => Ok(Validation::Valid), + Err(error) => Ok(Validation::Invalid(error.into())), + } + }) + .with_placeholder(default_password) + .with_help_message( + "Type \"none\" to leave it blank and use limited service\n\ + Your passphrase; this is the source of each of your coins' private keys. KEEP IT SAFE!", + ) + .prompt() + .map_err(|error| { + error!("Failed to get passphrase: {error}"); + }) + .map(|value| if "none" == value { None } else { Some(value) })?; + + Ok(()) + } + + #[inline] + fn inquire_rpc_password(&mut self) -> Result<(), ()> { + let allow_weak_password = self.allow_weak_password; + let validator = move |password: &str| { + if let Some(false) = allow_weak_password { + match password_policy::password_policy(password) { + Err(error) => Ok(Validation::Invalid(error.into())), + Ok(_) => Ok(Validation::Valid), + } + } else { + Ok(Validation::Valid) + } + }; + let default_password = Self::generate_password()?; + + self.rpc_password = Text::new("What is the rpc_password:") + .with_help_message("Your password for protected RPC methods (userpass)") + .with_validator(validator) + .with_default(default_password.as_str()) + .with_placeholder(default_password.as_str()) + .prompt() + .map_err(|error| { + error!("Failed to get rpc_password: {error}"); + })? + .into(); + Ok(()) + } + + fn generate_password() -> Result { + let pg = PasswordGenerator { + length: 8, + numbers: true, + lowercase_letters: true, + uppercase_letters: true, + symbols: true, + spaces: false, + exclude_similar_characters: true, + strict: true, + }; + let mut password = String::new(); + while password_policy::password_policy(&password).is_err() { + password = pg + .generate_one() + .map_err(|error| error!("Failed to generate password: {error}"))?; + } + Ok(password) + } + + #[inline] + fn inquire_allow_weak_password(&mut self) -> Result<(), ()> { + self.allow_weak_password = Confirm::new("Allow weak password:") + .with_default(false) + .with_placeholder("No") + .with_help_message(r#"If true, will allow low entropy rpc_password. If false rpc_password must not have 3 of the same characters in a row, must be at least 8 characters long, must contain at least one of each of the following: numeric, uppercase, lowercase, special character (e.g. !#$*). It also can not contain the word "password", or the chars <, >, and &. Defaults to false."#) + .prompt() + .map_err(|error| { + error!("Failed to get allow_weak_password: {error}"); + })? + .into(); + Ok(()) + } + + #[inline] + fn inquire_rpcip(&mut self) -> Result<(), ()> { + self.rpcip = CustomType::>::new("What is rpcip:") + .with_placeholder(DEFAULT_OPTION_PLACEHOLDER) + .with_help_message("IP address to bind to for RPC server. Optional, defaults to 127.0.0.1") + .prompt() + .map_err(|error| { + error!("Failed to get rpcip: {error}"); + })? + .into(); + Ok(()) + } + + #[inline] + fn inquire_rpcport(&mut self) -> Result<(), ()> { + let validator = |value: &InquireOption| -> Result { + match value { + InquireOption::None => Ok(Validation::Valid), + InquireOption::Some(value) => { + if (RPC_PORT_MIN..RPC_PORT_MAX + 1).contains(value) { + Ok(Validation::Valid) + } else { + Ok(Validation::Invalid( + format!("rpc_port is out of range: [{RPC_PORT_MIN}, {RPC_PORT_MAX}]").into(), + )) + } + }, + } + }; + self.rpcport = CustomType::>::new("What is the rpcport:") + .with_help_message(r#"Port to use for RPC communication. Optional, defaults to 7783"#) + .with_validator(validator) + .with_placeholder(DEFAULT_OPTION_PLACEHOLDER) + .prompt() + .map_err(|error| { + error!("Failed to get rpcport: {error}"); + })? + .into(); + Ok(()) + } + + #[inline] + fn inquire_rpc_local_only(&mut self) -> Result<(), ()> { + self.rpc_local_only = CustomType::>::new("What is rpc_local_only:") + .with_parser(OPTION_BOOL_PARSER) + .with_formatter(DEFAULT_OPTION_BOOL_FORMATTER) + .with_default_value_formatter(DEFAULT_DEFAULT_OPTION_BOOL_FORMATTER) + .with_default(InquireOption::None) + .with_help_message("If false the AtomicDEX API will allow rpc methods sent from external IP addresses. Optional, defaults to true. Warning: Only use this if you know what you are doing, and have put the appropriate security measures in place.") + .prompt() + .map_err(|error| { + error!("Failed to get rpc_local_only: {error}"); + })?.into(); + Ok(()) + } + + #[inline] + fn inquire_i_am_a_seed(&mut self) -> Result<(), ()> { + self.i_am_seed = CustomType::>::new("What is i_am_a_seed:") + .with_parser(OPTION_BOOL_PARSER) + .with_formatter(DEFAULT_OPTION_BOOL_FORMATTER) + .with_default_value_formatter(DEFAULT_DEFAULT_OPTION_BOOL_FORMATTER) + .with_default(InquireOption::None) + .with_help_message("Runs AtomicDEX API as a seed node mode (acting as a relay for AtomicDEX API clients). Optional, defaults to false. Use of this mode is not reccomended on the main network (7777) as it could result in a pubkey ban if non-compliant. on alternative testing or private networks, at least one seed node is required to relay information to other AtomicDEX API clients using the same netID.") + .prompt() + .map_err(|error| { + error!("Failed to get i_am_a_seed: {error}"); + })?.into(); + Ok(()) + } + + #[inline] + fn inquire_seednodes(&mut self) -> Result<(), ()> { + info!("Reading seed nodes until tap enter is met"); + loop { + let seednode: Option = CustomType::>::new("What is the next seednode:") + .with_help_message("Optional. If operating on a test or private netID, the IP address of at least one seed node is required (on the main network, these are already hardcoded)") + .with_placeholder(DEFAULT_OPTION_PLACEHOLDER) + .prompt() + .map_err(|error| { + error!("Failed to get seed node: {error}"); + })?.into(); + let Some(seednode) = seednode else { + break; + }; + self.seednodes.push(seednode); + } + Ok(()) + } + + #[inline] + fn inquire_hd_account_id(&mut self) -> Result<(), ()> { + self.hd_account_id = CustomType::>::new("What is hd_account_id:") + .with_help_message(r#"Optional. If this value is set, the AtomicDEX-API will work in only the HD derivation mode, coins will need to have a coin derivation path entry in the coins file for activation. The hd_account_id value effectively takes its place in the full derivation as follows: m/44'/COIN_ID'/'/CHAIN/ADDRESS_ID"#) + .with_placeholder(DEFAULT_OPTION_PLACEHOLDER) + .prompt() + .map_err(|error| { + error!("Failed to get hd_account_id: {error}"); + })? + .into(); + Ok(()) + } +} diff --git a/mm2src/adex_cli/src/scenarios/inquire_extentions.rs b/mm2src/adex_cli/src/scenarios/inquire_extentions.rs new file mode 100644 index 0000000000..9416fe01a5 --- /dev/null +++ b/mm2src/adex_cli/src/scenarios/inquire_extentions.rs @@ -0,0 +1,64 @@ +use inquire::parser::DEFAULT_BOOL_PARSER; +use std::str::FromStr; + +#[derive(Clone)] +pub enum InquireOption { + Some(T), + None, +} + +type OptionalConfirm = InquireOption; + +impl From> for Option { + fn from(value: InquireOption) -> Self { + match value { + InquireOption::None => None, + InquireOption::Some(value) => Some(value), + } + } +} + +impl FromStr for InquireOption +where + ::Err: ToString, +{ + type Err = T::Err; + fn from_str(s: &str) -> Result { + if s.is_empty() || s.to_lowercase() == "none" { + return Ok(InquireOption::None); + } + T::from_str(s).map(InquireOption::Some) + } +} + +impl ToString for InquireOption { + fn to_string(&self) -> String { + match self { + InquireOption::Some(value) => value.to_string(), + InquireOption::None => "None".to_string(), + } + } +} + +pub type OptionBoolFormatter<'a> = &'a dyn Fn(OptionalConfirm) -> String; +pub const DEFAULT_OPTION_BOOL_FORMATTER: OptionBoolFormatter = &|ans| -> String { + match ans { + InquireOption::None => String::new(), + InquireOption::Some(true) => String::from("yes"), + InquireOption::Some(false) => String::from("no"), + } +}; + +pub type OptionBoolParser<'a> = &'a dyn Fn(&str) -> Result, ()>; +pub const OPTION_BOOL_PARSER: OptionBoolParser = &|ans: &str| -> Result, ()> { + if ans.is_empty() { + return Ok(InquireOption::None); + } + DEFAULT_BOOL_PARSER(ans).map(InquireOption::Some) +}; + +pub const DEFAULT_DEFAULT_OPTION_BOOL_FORMATTER: OptionBoolFormatter = &|ans: InquireOption| match ans { + InquireOption::None => String::from("Tap enter to skip/yes/no"), + InquireOption::Some(true) => String::from("none/Yes/no"), + InquireOption::Some(false) => String::from("none/yes/No"), +}; diff --git a/mm2src/adex_cli/src/scenarios/mm2_proc_mng.rs b/mm2src/adex_cli/src/scenarios/mm2_proc_mng.rs new file mode 100644 index 0000000000..73a3460bbc --- /dev/null +++ b/mm2src/adex_cli/src/scenarios/mm2_proc_mng.rs @@ -0,0 +1,331 @@ +use common::log::{error, info}; +use std::env; +use std::path::PathBuf; + +#[cfg(not(target_os = "macos"))] +pub use sysinfo::{PidExt, ProcessExt, System, SystemExt}; + +#[cfg(windows)] +mod reexport { + pub use std::ffi::CString; + pub use std::mem; + pub use std::mem::size_of; + pub use std::ptr::null; + pub use std::u32; + pub use winapi::um::processthreadsapi::{CreateProcessA, OpenProcess, TerminateProcess, PROCESS_INFORMATION, + STARTUPINFOA}; + pub use winapi::um::winnt::{PROCESS_TERMINATE, SYNCHRONIZE}; + + pub const MM2_BINARY: &str = "mm2.exe"; +} + +#[cfg(windows)] use reexport::*; + +#[cfg(all(unix, not(target_os = "macos")))] +mod unix_not_macos_reexport { + pub use std::process::{Command, Stdio}; + + pub const KILL_CMD: &str = "kill"; +} + +#[cfg(all(unix, not(target_os = "macos")))] +use unix_not_macos_reexport::*; + +#[cfg(unix)] +mod unix_reexport { + pub const MM2_BINARY: &str = "mm2"; +} + +#[cfg(unix)] use unix_reexport::*; + +#[cfg(target_os = "macos")] +mod macos_reexport { + pub use std::fs; + pub const LAUNCH_CTL_COOL_DOWN_TIMEOUT_MS: u64 = 500; + pub use common::log::debug; + pub use std::process::{Command, Stdio}; + pub use std::thread::sleep; + pub use std::time::Duration; + pub const LAUNCHCTL_MM2_ID: &str = "com.mm2.daemon"; +} + +#[cfg(target_os = "macos")] use macos_reexport::*; + +#[cfg(not(target_os = "macos"))] +pub fn get_status() { + let pids = find_proc_by_name(MM2_BINARY); + if pids.is_empty() { + info!("Process not found: {MM2_BINARY}"); + } + pids.iter().map(u32::to_string).for_each(|pid| { + info!("Found {MM2_BINARY} is running, pid: {pid}"); + }); +} + +#[cfg(not(target_os = "macos"))] +fn find_proc_by_name(pname: &'_ str) -> Vec { + let s = System::new_all(); + + s.processes() + .iter() + .filter(|(_, process)| process.name() == pname) + .map(|(pid, _)| pid.as_u32()) + .collect() +} + +fn get_mm2_binary_path() -> Result { + let mut dir = env::current_exe().map_err(|error| { + error!("Failed to get current binary dir: {error}"); + })?; + dir.pop(); + dir.push(MM2_BINARY); + Ok(dir) +} + +#[cfg(not(target_os = "macos"))] +pub fn start_process(mm2_cfg_file: &Option, coins_file: &Option, log_file: &Option) { + if let Some(mm2_cfg_file) = mm2_cfg_file { + info!("Set env MM_CONF_PATH as: {mm2_cfg_file}"); + env::set_var("MM_CONF_PATH", mm2_cfg_file); + } + if let Some(coins_file) = coins_file { + info!("Set env MM_COINS_PATH as: {coins_file}"); + env::set_var("MM_COINS_PATH", coins_file); + } + if let Some(log_file) = log_file { + info!("Set env MM_LOG as: {log_file}"); + env::set_var("MM_LOG", log_file); + } + + let Ok(mm2_binary) = get_mm2_binary_path() else { return; }; + if !mm2_binary.exists() { + error!("Failed to start mm2, no file: {mm2_binary:?}"); + return; + } + start_process_impl(mm2_binary); +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub fn start_process_impl(mm2_binary: PathBuf) { + let mut command = Command::new(&mm2_binary); + let file_name = mm2_binary.file_name().expect("No file_name in mm2_binary"); + let process = match command.stdout(Stdio::null()).stdout(Stdio::null()).spawn() { + Ok(process) => process, + Err(error) => { + error!("Failed to start process: {mm2_binary:?}, error: {error}"); + return; + }, + }; + let pid = process.id(); + std::mem::forget(process); + info!("Started child process: {file_name:?}, pid: {pid}"); +} + +#[cfg(windows)] +pub fn start_process_impl(mm2_binary: PathBuf) { + let Some(program) = mm2_binary.to_str() else { + error!("Failed to cast mm2_binary to &str"); + return; + }; + let program = match CString::new(program) { + Ok(program) => program, + Err(error) => { + error!("Failed to construct CString program path: {error}"); + return; + }, + }; + + let mut startup_info: STARTUPINFOA = unsafe { mem::zeroed() }; + startup_info.cb = size_of::() as u32; + let mut process_info: PROCESS_INFORMATION = unsafe { mem::zeroed() }; + + let result = unsafe { + CreateProcessA( + null(), + program.into_raw() as *mut i8, + std::ptr::null_mut(), + std::ptr::null_mut(), + 0, + 0, + std::ptr::null_mut(), + std::ptr::null(), + &mut startup_info, + &mut process_info, + ) + }; + + match result { + 0 => error!("Failed to start: {MM2_BINARY}"), + _ => info!("Successfully started: {MM2_BINARY}"), + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub fn stop_process() { + let pids = find_proc_by_name(MM2_BINARY); + if pids.is_empty() { + info!("Process not found: {MM2_BINARY}"); + } + pids.iter().map(u32::to_string).for_each(|pid| { + match Command::new(KILL_CMD) + .arg(&pid) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(status) => { + if status.success() { + info!("Process killed: {MM2_BINARY}:{pid}") + } else { + error!("Failed to kill process: {MM2_BINARY}:{pid}") + } + }, + Err(e) => error!("Failed to kill process: {MM2_BINARY}:{pid}. Error: {e}"), + }; + }); +} + +#[cfg(windows)] +pub fn stop_process() { + let processes = find_proc_by_name(MM2_BINARY); + for pid in processes { + info!("Terminate process: {}", pid); + unsafe { + let handy = OpenProcess(SYNCHRONIZE | PROCESS_TERMINATE, true as i32, pid); + TerminateProcess(handy, 1); + } + } +} + +#[cfg(target_os = "macos")] +pub fn start_process(mm2_cfg_file: &Option, coins_file: &Option, log_file: &Option) { + let Ok(mm2_binary) = get_mm2_binary_path() else { return; }; + + let Ok(current_dir) = env::current_dir() else { + error!("Failed to get current_dir"); + return + }; + + let Ok(plist_path) = get_plist_path() else {return;}; + + let plist = format!( + r#" + + + + Label + {} + ProgramArguments + + {} + + WorkingDirectory + {} + EnvironmentVariables + {}{}{} + RunAtLoad + + KeepAlive + + + "#, + LAUNCHCTL_MM2_ID, + mm2_binary.display(), + current_dir.display(), + log_file + .as_deref() + .map(|log_file| format!("MM_LOG{log_file}")) + .unwrap_or_default(), + mm2_cfg_file + .as_deref() + .map(|cfg_file| format!("MM_CONF_PATH{cfg_file}")) + .unwrap_or_default(), + coins_file + .as_deref() + .map(|coins_file| format!("MM_COINS_PATH{coins_file}")) + .unwrap_or_default(), + ); + + if let Err(error) = fs::write(&plist_path, plist) { + error!("Failed to write plist file: {error}"); + return; + } + + match Command::new("launchctl") + .arg("enable") + .arg(format!("system/{LAUNCHCTL_MM2_ID}").as_str()) + .spawn() + { + Ok(_) => debug!("Successfully enabled using launchctl, label: {LAUNCHCTL_MM2_ID}"), + Err(error) => error!("Failed to enable process: {error}"), + } + + match Command::new("launchctl").arg("load").arg(&plist_path).spawn() { + Ok(_) => debug!("Successfully loaded using launchctl, label: {LAUNCHCTL_MM2_ID}"), + Err(error) => error!("Failed to load process: {error}"), + } + + match Command::new("launchctl").args(["start", LAUNCHCTL_MM2_ID]).spawn() { + Ok(_) => info!("Successfully started using launchctl, label: {LAUNCHCTL_MM2_ID}"), + Err(error) => error!("Failed to start process: {error}"), + } +} + +#[cfg(target_os = "macos")] +fn get_plist_path() -> Result { + match env::current_dir() { + Err(error) => { + error!("Failed to get current_dir to construct plist_path: {error}"); + Err(()) + }, + Ok(mut current_dir) => { + current_dir.push(&format!("{LAUNCHCTL_MM2_ID}.plist")); + Ok(current_dir) + }, + } +} + +#[cfg(target_os = "macos")] +pub fn stop_process() { + let Ok(plist_path) = get_plist_path() else { return; }; + + if let Err(error) = Command::new("launchctl").arg("unload").arg(&plist_path).spawn() { + error!("Failed to unload process using launchctl: {}", error); + } else { + info!("mm2 successfully stopped by launchctl"); + } + sleep(Duration::from_millis(LAUNCH_CTL_COOL_DOWN_TIMEOUT_MS)); + if let Err(err) = fs::remove_file(&plist_path) { + error!("Failed to remove plist file: {}", err); + } +} + +#[cfg(target_os = "macos")] +pub fn get_status() { + let output = Command::new("launchctl") + .args(["list", LAUNCHCTL_MM2_ID]) + .output() + .unwrap(); + + if !output.status.success() { + info!("Service '{LAUNCHCTL_MM2_ID}' is not running"); + return; + } + + if let Some(found) = String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| line.contains("PID")) + .last() + { + let pid = found + .trim() + .matches(char::is_numeric) + .fold(String::default(), |mut pid, ch| { + pid.push_str(ch); + pid + }); + info!("Service '{LAUNCHCTL_MM2_ID}' is running under launchctl, pid: {}", pid); + } else { + info!("Service '{LAUNCHCTL_MM2_ID}' is not running"); + }; +} diff --git a/mm2src/adex_cli/src/scenarios/mod.rs b/mm2src/adex_cli/src/scenarios/mod.rs new file mode 100644 index 0000000000..2a9d637f01 --- /dev/null +++ b/mm2src/adex_cli/src/scenarios/mod.rs @@ -0,0 +1,16 @@ +mod helpers; +mod init_coins; +mod init_mm2_cfg; +mod inquire_extentions; +mod mm2_proc_mng; + +use init_coins::init_coins; +use init_mm2_cfg::init_mm2_cfg; +pub use mm2_proc_mng::{get_status, start_process, stop_process}; + +pub fn init(cfg_file: &str, coins_file: &str) { + if init_mm2_cfg(cfg_file).is_err() { + return; + } + let _ = init_coins(coins_file); +} diff --git a/mm2src/common/Cargo.toml b/mm2src/common/Cargo.toml index dfe77611e4..d970affc63 100644 --- a/mm2src/common/Cargo.toml +++ b/mm2src/common/Cargo.toml @@ -19,6 +19,7 @@ backtrace = "0.3" bytes = "1.1" cfg-if = "1.0" crossbeam = "0.8" +derive_more = "0.99" fnv = "1.0.6" futures01 = { version = "0.1", package = "futures" } futures = { version = "0.3", package = "futures", features = ["compat", "async-await", "thread-pool"] } @@ -33,6 +34,7 @@ parking_lot = { version = "0.12.0", features = ["nightly"] } parking_lot_core = { version = "0.6", features = ["nightly"] } primitive-types = "0.11.1" rand = { version = "0.7", features = ["std", "small_rng"] } +regex = "1" serde = "1" serde_derive = "1" serde_json = { version = "1", features = ["preserve_order", "raw_value"] } diff --git a/mm2src/common/build.rs b/mm2src/common/build.rs index 2850c0141a..0230591b5b 100644 --- a/mm2src/common/build.rs +++ b/mm2src/common/build.rs @@ -83,7 +83,8 @@ fn build_c_code() { return; } - if cfg!(windows) { + let target_os = var("CARGO_CFG_TARGET_FAMILY").expect("!CARGO_CFG_TARGET_FAMILY"); + if target_os == "windows" { // Link in the Windows-specific crash handling code. let lm_seh = last_modified_sec(&"seh.c").expect("Can't stat seh.c"); let out_dir = var("OUT_DIR").expect("!OUT_DIR"); diff --git a/mm2src/common/common.rs b/mm2src/common/common.rs index e9000bd0da..b665a59305 100644 --- a/mm2src/common/common.rs +++ b/mm2src/common/common.rs @@ -118,6 +118,7 @@ pub mod custom_futures; pub mod custom_iter; #[path = "executor/mod.rs"] pub mod executor; pub mod number_type_casting; +pub mod password_policy; pub mod seri; #[path = "patterns/state_machine.rs"] pub mod state_machine; pub mod time_cache; diff --git a/mm2src/common/password_policy.rs b/mm2src/common/password_policy.rs new file mode 100644 index 0000000000..5ffe3720eb --- /dev/null +++ b/mm2src/common/password_policy.rs @@ -0,0 +1,116 @@ +use derive_more::Display; +use regex::Regex; + +pub const PASSWORD_MAXIMUM_CONSECUTIVE_CHARACTERS: usize = 3; + +#[derive(Debug, Display, PartialEq)] +pub enum PasswordPolicyError { + #[display(fmt = "Password can't contain the word password")] + ContainsTheWordPassword, + #[display(fmt = "Password length should be at least 8 characters long")] + PasswordLength, + #[display(fmt = "Password should contain at least 1 digit")] + PasswordMissDigit, + #[display(fmt = "Password should contain at least 1 lowercase character")] + PasswordMissLowercase, + #[display(fmt = "Password should contain at least 1 uppercase character")] + PasswordMissUppercase, + #[display(fmt = "Password should contain at least 1 special character")] + PasswordMissSpecialCharacter, + #[display(fmt = "Password can't contain the same character 3 times in a row")] + PasswordConsecutiveCharactersExceeded, +} + +pub fn password_policy(password: &str) -> Result<(), PasswordPolicyError> { + lazy_static! { + static ref REGEX_NUMBER: Regex = Regex::new(".*[0-9].*").unwrap(); + static ref REGEX_LOWERCASE: Regex = Regex::new(".*[a-z].*").unwrap(); + static ref REGEX_UPPERCASE: Regex = Regex::new(".*[A-Z].*").unwrap(); + static ref REGEX_SPECIFIC_CHARS: Regex = Regex::new(".*[^A-Za-z0-9].*").unwrap(); + } + if password.to_lowercase().contains("password") { + return Err(PasswordPolicyError::ContainsTheWordPassword); + } + let password_len = password.chars().count(); + if (0..8).contains(&password_len) { + return Err(PasswordPolicyError::PasswordLength); + } + if !REGEX_NUMBER.is_match(password) { + return Err(PasswordPolicyError::PasswordMissDigit); + } + if !REGEX_LOWERCASE.is_match(password) { + return Err(PasswordPolicyError::PasswordMissLowercase); + } + if !REGEX_UPPERCASE.is_match(password) { + return Err(PasswordPolicyError::PasswordMissUppercase); + } + if !REGEX_SPECIFIC_CHARS.is_match(password) { + return Err(PasswordPolicyError::PasswordMissSpecialCharacter); + } + if !super::is_acceptable_input_on_repeated_characters(password, PASSWORD_MAXIMUM_CONSECUTIVE_CHARACTERS) { + return Err(PasswordPolicyError::PasswordConsecutiveCharactersExceeded); + } + Ok(()) +} + +#[test] +fn check_password_policy() { + use crate::password_policy::PasswordPolicyError; + // Length + assert_eq!( + password_policy("1234567").unwrap_err(), + PasswordPolicyError::PasswordLength + ); + + // Miss special character + assert_eq!( + password_policy("pass123worD").unwrap_err(), + PasswordPolicyError::PasswordMissSpecialCharacter + ); + + // Miss digit + assert_eq!( + password_policy("SecretPassSoStrong$*").unwrap_err(), + PasswordPolicyError::PasswordMissDigit + ); + + // Miss lowercase + assert_eq!( + password_policy("SECRETPASS-SOSTRONG123*").unwrap_err(), + PasswordPolicyError::PasswordMissLowercase + ); + + // Miss uppercase + assert_eq!( + password_policy("secretpass-sostrong123*").unwrap_err(), + PasswordPolicyError::PasswordMissUppercase + ); + + // Contains the same character 3 times in a row + assert_eq!( + password_policy("SecretPassSoStrong123*aaa").unwrap_err(), + PasswordPolicyError::PasswordConsecutiveCharactersExceeded + ); + + // Contains Password uppercase + assert_eq!( + password_policy("Password123*$").unwrap_err(), + PasswordPolicyError::ContainsTheWordPassword + ); + + // Contains Password lowercase + assert_eq!( + password_policy("Foopassword123*$").unwrap_err(), + PasswordPolicyError::ContainsTheWordPassword + ); + + // Check valid long password + let long_pass = "SecretPassSoStrong*!1234567891012"; + assert!(long_pass.len() > 32); + assert!(password_policy(long_pass).is_ok()); + + // Valid passwords + password_policy("StrongPass123*").unwrap(); + password_policy(r#"StrongPass123[]\± "#).unwrap(); + password_policy("StrongPass123£StrongPass123£Pass").unwrap(); +} diff --git a/mm2src/mm2_main/src/mm2.rs b/mm2src/mm2_main/src/mm2.rs index c31aff6439..6103362ad4 100644 --- a/mm2src/mm2_main/src/mm2.rs +++ b/mm2src/mm2_main/src/mm2.rs @@ -28,6 +28,7 @@ use common::crash_reports::init_crash_reports; use common::double_panic_crash; use common::log::LogLevel; +use common::password_policy::password_policy; use mm2_core::mm_ctx::MmCtxBuilder; #[cfg(feature = "custom-swap-locktime")] use common::log::warn; @@ -36,10 +37,7 @@ use lp_swap::PAYMENT_LOCKTIME; #[cfg(feature = "custom-swap-locktime")] use std::sync::atomic::Ordering; -use derive_more::Display; use gstuff::slurp; -use lazy_static::lazy_static; -use regex::Regex; use serde::ser::Serialize; use serde_json::{self as json, Value as Json}; @@ -99,117 +97,6 @@ impl LpMainParams { } } -#[derive(Debug, Display, PartialEq)] -pub enum PasswordPolicyError { - #[display(fmt = "Password can't contain the word password")] - ContainsTheWordPassword, - #[display(fmt = "Password length should be at least 8 characters long")] - PasswordLength, - #[display(fmt = "Password should contain at least 1 digit")] - PasswordMissDigit, - #[display(fmt = "Password should contain at least 1 lowercase character")] - PasswordMissLowercase, - #[display(fmt = "Password should contain at least 1 uppercase character")] - PasswordMissUppercase, - #[display(fmt = "Password should contain at least 1 special character")] - PasswordMissSpecialCharacter, - #[display(fmt = "Password can't contain the same character 3 times in a row")] - PasswordConsecutiveCharactersExceeded, -} - -pub fn password_policy(password: &str) -> Result<(), MmError> { - lazy_static! { - static ref REGEX_NUMBER: Regex = Regex::new(".*[0-9].*").unwrap(); - static ref REGEX_LOWERCASE: Regex = Regex::new(".*[a-z].*").unwrap(); - static ref REGEX_UPPERCASE: Regex = Regex::new(".*[A-Z].*").unwrap(); - static ref REGEX_SPECIFIC_CHARS: Regex = Regex::new(".*[^A-Za-z0-9].*").unwrap(); - } - if password.to_lowercase().contains("password") { - return MmError::err(PasswordPolicyError::ContainsTheWordPassword); - } - let password_len = password.chars().count(); - if (0..8).contains(&password_len) { - return MmError::err(PasswordPolicyError::PasswordLength); - } - if !REGEX_NUMBER.is_match(password) { - return MmError::err(PasswordPolicyError::PasswordMissDigit); - } - if !REGEX_LOWERCASE.is_match(password) { - return MmError::err(PasswordPolicyError::PasswordMissLowercase); - } - if !REGEX_UPPERCASE.is_match(password) { - return MmError::err(PasswordPolicyError::PasswordMissUppercase); - } - if !REGEX_SPECIFIC_CHARS.is_match(password) { - return MmError::err(PasswordPolicyError::PasswordMissSpecialCharacter); - } - if !common::is_acceptable_input_on_repeated_characters(password, PASSWORD_MAXIMUM_CONSECUTIVE_CHARACTERS) { - return MmError::err(PasswordPolicyError::PasswordConsecutiveCharactersExceeded); - } - Ok(()) -} - -#[test] -fn check_password_policy() { - // Length - assert_eq!( - password_policy("1234567").unwrap_err().into_inner(), - PasswordPolicyError::PasswordLength - ); - - // Miss special character - assert_eq!( - password_policy("pass123worD").unwrap_err().into_inner(), - PasswordPolicyError::PasswordMissSpecialCharacter - ); - - // Miss digit - assert_eq!( - password_policy("SecretPassSoStrong$*").unwrap_err().into_inner(), - PasswordPolicyError::PasswordMissDigit - ); - - // Miss lowercase - assert_eq!( - password_policy("SECRETPASS-SOSTRONG123*").unwrap_err().into_inner(), - PasswordPolicyError::PasswordMissLowercase - ); - - // Miss uppercase - assert_eq!( - password_policy("secretpass-sostrong123*").unwrap_err().into_inner(), - PasswordPolicyError::PasswordMissUppercase - ); - - // Contains the same character 3 times in a row - assert_eq!( - password_policy("SecretPassSoStrong123*aaa").unwrap_err().into_inner(), - PasswordPolicyError::PasswordConsecutiveCharactersExceeded - ); - - // Contains Password uppercase - assert_eq!( - password_policy("Password123*$").unwrap_err().into_inner(), - PasswordPolicyError::ContainsTheWordPassword - ); - - // Contains Password lowercase - assert_eq!( - password_policy("Foopassword123*$").unwrap_err().into_inner(), - PasswordPolicyError::ContainsTheWordPassword - ); - - // Check valid long password - let long_pass = "SecretPassSoStrong*!1234567891012"; - assert!(long_pass.len() > 32); - assert!(password_policy(long_pass).is_ok()); - - // Valid passwords - password_policy("StrongPass123*").unwrap(); - password_policy(r#"StrongPass123[]\± "#).unwrap(); - password_policy("StrongPass123£StrongPass123£Pass").unwrap(); -} - #[cfg(feature = "custom-swap-locktime")] /// Reads `payment_locktime` from conf arg and assigns it into `PAYMENT_LOCKTIME` in lp_swap. /// Assigns 900 if `payment_locktime` is invalid or not provided. @@ -312,7 +199,6 @@ Some (but not all) of the JSON configuration parameters (* - required): seednodes .. Seednode IPs that node will use. At least one seed IP must be present if the node is not a seed itself. stderr .. Print a message to stderr and exit. - userhome .. System home directory of a user ('/root' by default). wif .. `1` to add WIFs to the information we provide about a coin. Environment variables: