|
| 1 | +use crate::cli::LdkUserInfo; |
| 2 | +use bitcoin::network::constants::Network; |
| 3 | +use lightning::ln::msgs::NetAddress; |
| 4 | +use std::collections::HashMap; |
| 5 | +use std::env; |
| 6 | +use std::fs; |
| 7 | +use std::net::IpAddr; |
| 8 | +use std::path::{Path, PathBuf}; |
| 9 | +use std::str::FromStr; |
| 10 | + |
| 11 | +pub(crate) fn parse_startup_args() -> Result<LdkUserInfo, ()> { |
| 12 | + if env::args().len() < 3 { |
| 13 | + println!("ldk-tutorial-node requires 3 arguments: `cargo run [<bitcoind-rpc-username>:<bitcoind-rpc-password>@]<bitcoind-rpc-host>:<bitcoind-rpc-port> ldk_storage_directory_path [<ldk-incoming-peer-listening-port>] [bitcoin-network] [announced-node-name announced-listen-addr*]`"); |
| 14 | + return Err(()); |
| 15 | + } |
| 16 | + let bitcoind_rpc_info = env::args().skip(1).next().unwrap(); |
| 17 | + let bitcoind_rpc_info_parts: Vec<&str> = bitcoind_rpc_info.rsplitn(2, "@").collect(); |
| 18 | + |
| 19 | + // Parse rpc auth after getting network for default .cookie location |
| 20 | + let bitcoind_rpc_path: Vec<&str> = bitcoind_rpc_info_parts[0].split(":").collect(); |
| 21 | + if bitcoind_rpc_path.len() != 2 { |
| 22 | + println!("ERROR: bad bitcoind RPC path provided"); |
| 23 | + return Err(()); |
| 24 | + } |
| 25 | + let bitcoind_rpc_host = bitcoind_rpc_path[0].to_string(); |
| 26 | + let bitcoind_rpc_port = bitcoind_rpc_path[1].parse::<u16>().unwrap(); |
| 27 | + |
| 28 | + let ldk_storage_dir_path = env::args().skip(2).next().unwrap(); |
| 29 | + |
| 30 | + let mut ldk_peer_port_set = true; |
| 31 | + let ldk_peer_listening_port: u16 = match env::args().skip(3).next().map(|p| p.parse()) { |
| 32 | + Some(Ok(p)) => p, |
| 33 | + Some(Err(_)) => { |
| 34 | + ldk_peer_port_set = false; |
| 35 | + 9735 |
| 36 | + } |
| 37 | + None => { |
| 38 | + ldk_peer_port_set = false; |
| 39 | + 9735 |
| 40 | + } |
| 41 | + }; |
| 42 | + |
| 43 | + let mut arg_idx = match ldk_peer_port_set { |
| 44 | + true => 4, |
| 45 | + false => 3, |
| 46 | + }; |
| 47 | + let network: Network = match env::args().skip(arg_idx).next().as_ref().map(String::as_str) { |
| 48 | + Some("testnet") => Network::Testnet, |
| 49 | + Some("regtest") => Network::Regtest, |
| 50 | + Some("signet") => Network::Signet, |
| 51 | + Some(net) => { |
| 52 | + panic!("Unsupported network provided. Options are: `regtest`, `testnet`, and `signet`. Got {}", net); |
| 53 | + } |
| 54 | + None => Network::Testnet, |
| 55 | + }; |
| 56 | + |
| 57 | + let (bitcoind_rpc_username, bitcoind_rpc_password) = if bitcoind_rpc_info_parts.len() == 1 { |
| 58 | + get_rpc_auth_from_cookie(None, Some(network), None) |
| 59 | + .or(get_rpc_auth_from_env_file(None)) |
| 60 | + .or(get_rpc_auth_from_env_vars()) |
| 61 | + .or({ |
| 62 | + println!("ERROR: unable to get bitcoind RPC username and password"); |
| 63 | + print_rpc_auth_help(); |
| 64 | + Err(()) |
| 65 | + })? |
| 66 | + } else if bitcoind_rpc_info_parts.len() == 2 { |
| 67 | + parse_rpc_auth(bitcoind_rpc_info_parts[1])? |
| 68 | + } else { |
| 69 | + println!("ERROR: bad bitcoind RPC URL provided"); |
| 70 | + return Err(()); |
| 71 | + }; |
| 72 | + |
| 73 | + let ldk_announced_node_name = match env::args().skip(arg_idx + 1).next().as_ref() { |
| 74 | + Some(s) => { |
| 75 | + if s.len() > 32 { |
| 76 | + panic!("Node Alias can not be longer than 32 bytes"); |
| 77 | + } |
| 78 | + arg_idx += 1; |
| 79 | + let mut bytes = [0; 32]; |
| 80 | + bytes[..s.len()].copy_from_slice(s.as_bytes()); |
| 81 | + bytes |
| 82 | + } |
| 83 | + None => [0; 32], |
| 84 | + }; |
| 85 | + |
| 86 | + let mut ldk_announced_listen_addr = Vec::new(); |
| 87 | + loop { |
| 88 | + match env::args().skip(arg_idx + 1).next().as_ref() { |
| 89 | + Some(s) => match IpAddr::from_str(s) { |
| 90 | + Ok(IpAddr::V4(a)) => { |
| 91 | + ldk_announced_listen_addr |
| 92 | + .push(NetAddress::IPv4 { addr: a.octets(), port: ldk_peer_listening_port }); |
| 93 | + arg_idx += 1; |
| 94 | + } |
| 95 | + Ok(IpAddr::V6(a)) => { |
| 96 | + ldk_announced_listen_addr |
| 97 | + .push(NetAddress::IPv6 { addr: a.octets(), port: ldk_peer_listening_port }); |
| 98 | + arg_idx += 1; |
| 99 | + } |
| 100 | + Err(_) => panic!("Failed to parse announced-listen-addr into an IP address"), |
| 101 | + }, |
| 102 | + None => break, |
| 103 | + } |
| 104 | + } |
| 105 | + |
| 106 | + Ok(LdkUserInfo { |
| 107 | + bitcoind_rpc_username, |
| 108 | + bitcoind_rpc_password, |
| 109 | + bitcoind_rpc_host, |
| 110 | + bitcoind_rpc_port, |
| 111 | + ldk_storage_dir_path, |
| 112 | + ldk_peer_listening_port, |
| 113 | + ldk_announced_listen_addr, |
| 114 | + ldk_announced_node_name, |
| 115 | + network, |
| 116 | + }) |
| 117 | +} |
| 118 | + |
| 119 | +// Default datadir relative to home directory |
| 120 | +#[cfg(target_os = "windows")] |
| 121 | +const DEFAULT_BITCOIN_DATADIR: &str = "AppData/Roaming/Bitcoin"; |
| 122 | +#[cfg(target_os = "linux")] |
| 123 | +const DEFAULT_BITCOIN_DATADIR: &str = ".bitcoin"; |
| 124 | +#[cfg(target_os = "macos")] |
| 125 | +const DEFAULT_BITCOIN_DATADIR: &str = "Library/Application Support/Bitcoin"; |
| 126 | + |
| 127 | +// Environment variable/.env keys |
| 128 | +const BITCOIND_RPC_USER_KEY: &str = "RPC_USER"; |
| 129 | +const BITCOIND_RPC_PASSWORD_KEY: &str = "RPC_PASSWORD"; |
| 130 | + |
| 131 | +fn print_rpc_auth_help() { |
| 132 | + // Get the default data directory |
| 133 | + let home_dir = env::home_dir() |
| 134 | + .as_ref() |
| 135 | + .map(|ref p| p.to_str()) |
| 136 | + .flatten() |
| 137 | + .unwrap_or("$HOME") |
| 138 | + .replace("\\", "/"); |
| 139 | + let data_dir = format!("{}/{}", home_dir, DEFAULT_BITCOIN_DATADIR); |
| 140 | + println!("To provide the bitcoind RPC username and password, you can either:"); |
| 141 | + println!( |
| 142 | + "1. Provide the username and password as the first argument to this program in the format: \ |
| 143 | + <bitcoind-rpc-username>:<bitcoind-rpc-password>@<bitcoind-rpc-host>:<bitcoind-rpc-port>" |
| 144 | + ); |
| 145 | + println!("2. Provide <bitcoind-rpc-username>:<bitcoind-rpc-password> in a .cookie file in the default \ |
| 146 | + bitcoind data directory (automatically created by bitcoind on startup): `{}`", data_dir); |
| 147 | + println!( |
| 148 | + "3. Set the {} and {} environment variables", |
| 149 | + BITCOIND_RPC_USER_KEY, BITCOIND_RPC_PASSWORD_KEY |
| 150 | + ); |
| 151 | + println!( |
| 152 | + "4. Provide {} and {} fields in a .env file in the current directory", |
| 153 | + BITCOIND_RPC_USER_KEY, BITCOIND_RPC_PASSWORD_KEY |
| 154 | + ); |
| 155 | +} |
| 156 | + |
| 157 | +fn parse_rpc_auth(rpc_auth: &str) -> Result<(String, String), ()> { |
| 158 | + let rpc_auth_info: Vec<&str> = rpc_auth.split(':').collect(); |
| 159 | + if rpc_auth_info.len() != 2 { |
| 160 | + println!("ERROR: bad bitcoind RPC username/password combo provided"); |
| 161 | + return Err(()); |
| 162 | + } |
| 163 | + let rpc_username = rpc_auth_info[0].to_string(); |
| 164 | + let rpc_password = rpc_auth_info[1].to_string(); |
| 165 | + Ok((rpc_username, rpc_password)) |
| 166 | +} |
| 167 | + |
| 168 | +fn get_cookie_path( |
| 169 | + data_dir: Option<(&str, bool)>, network: Option<Network>, cookie_file_name: Option<&str>, |
| 170 | +) -> Result<PathBuf, ()> { |
| 171 | + let data_dir_path = match data_dir { |
| 172 | + Some((dir, true)) => env::home_dir().ok_or(())?.join(dir), |
| 173 | + Some((dir, false)) => PathBuf::from(dir), |
| 174 | + None => env::home_dir().ok_or(())?.join(DEFAULT_BITCOIN_DATADIR), |
| 175 | + }; |
| 176 | + |
| 177 | + let data_dir_path_with_net = match network { |
| 178 | + Some(Network::Testnet) => data_dir_path.join("testnet"), |
| 179 | + Some(Network::Regtest) => data_dir_path.join("regtest"), |
| 180 | + Some(Network::Signet) => data_dir_path.join("signet"), |
| 181 | + _ => data_dir_path, |
| 182 | + }; |
| 183 | + |
| 184 | + let cookie_path = data_dir_path_with_net.join(cookie_file_name.unwrap_or(".cookie")); |
| 185 | + |
| 186 | + Ok(cookie_path) |
| 187 | +} |
| 188 | + |
| 189 | +fn get_rpc_auth_from_cookie( |
| 190 | + data_dir: Option<(&str, bool)>, network: Option<Network>, cookie_file_name: Option<&str>, |
| 191 | +) -> Result<(String, String), ()> { |
| 192 | + let cookie_path = get_cookie_path(data_dir, network, cookie_file_name)?; |
| 193 | + let cookie_contents = fs::read_to_string(cookie_path).or(Err(()))?; |
| 194 | + parse_rpc_auth(&cookie_contents) |
| 195 | +} |
| 196 | + |
| 197 | +fn get_rpc_auth_from_env_vars() -> Result<(String, String), ()> { |
| 198 | + if let (Ok(username), Ok(password)) = |
| 199 | + (env::var(BITCOIND_RPC_USER_KEY), env::var(BITCOIND_RPC_PASSWORD_KEY)) |
| 200 | + { |
| 201 | + Ok((username, password)) |
| 202 | + } else { |
| 203 | + Err(()) |
| 204 | + } |
| 205 | +} |
| 206 | + |
| 207 | +fn get_rpc_auth_from_env_file(env_file_name: Option<&str>) -> Result<(String, String), ()> { |
| 208 | + let env_file_map = parse_env_file(env_file_name)?; |
| 209 | + if let (Some(username), Some(password)) = |
| 210 | + (env_file_map.get(BITCOIND_RPC_USER_KEY), env_file_map.get(BITCOIND_RPC_PASSWORD_KEY)) |
| 211 | + { |
| 212 | + Ok((username.to_string(), password.to_string())) |
| 213 | + } else { |
| 214 | + Err(()) |
| 215 | + } |
| 216 | +} |
| 217 | + |
| 218 | +fn parse_env_file(env_file_name: Option<&str>) -> Result<HashMap<String, String>, ()> { |
| 219 | + // Default .env file name is .env |
| 220 | + let env_file_name = match env_file_name { |
| 221 | + Some(filename) => filename, |
| 222 | + None => ".env", |
| 223 | + }; |
| 224 | + |
| 225 | + // Read .env file |
| 226 | + let env_file_path = Path::new(env_file_name); |
| 227 | + let env_file_contents = fs::read_to_string(env_file_path).or(Err(()))?; |
| 228 | + |
| 229 | + // Collect key-value pairs from .env file into a map |
| 230 | + let mut env_file_map: HashMap<String, String> = HashMap::new(); |
| 231 | + for line in env_file_contents.lines() { |
| 232 | + let line_parts: Vec<&str> = line.splitn(2, '=').collect(); |
| 233 | + if line_parts.len() != 2 { |
| 234 | + println!("ERROR: bad .env file format"); |
| 235 | + return Err(()); |
| 236 | + } |
| 237 | + env_file_map.insert(line_parts[0].to_string(), line_parts[1].to_string()); |
| 238 | + } |
| 239 | + |
| 240 | + Ok(env_file_map) |
| 241 | +} |
| 242 | + |
| 243 | +#[cfg(test)] |
| 244 | +mod rpc_auth_tests { |
| 245 | + use super::*; |
| 246 | + |
| 247 | + const TEST_ENV_FILE: &str = "test_data/test_env_file"; |
| 248 | + const TEST_ENV_FILE_BAD: &str = "test_data/test_env_file_bad"; |
| 249 | + const TEST_ABSENT_FILE: &str = "nonexistent_file"; |
| 250 | + const TEST_DATA_DIR: &str = "test_data"; |
| 251 | + const TEST_COOKIE: &str = "test_cookie"; |
| 252 | + const TEST_COOKIE_BAD: &str = "test_cookie_bad"; |
| 253 | + const EXPECTED_USER: &str = "testuser"; |
| 254 | + const EXPECTED_PASSWORD: &str = "testpassword"; |
| 255 | + |
| 256 | + #[test] |
| 257 | + fn test_parse_rpc_auth_success() { |
| 258 | + let (username, password) = parse_rpc_auth("testuser:testpassword").unwrap(); |
| 259 | + assert_eq!(username, EXPECTED_USER); |
| 260 | + assert_eq!(password, EXPECTED_PASSWORD); |
| 261 | + } |
| 262 | + |
| 263 | + #[test] |
| 264 | + fn test_parse_rpc_auth_fail() { |
| 265 | + let result = parse_rpc_auth("testuser"); |
| 266 | + assert!(result.is_err()); |
| 267 | + } |
| 268 | + |
| 269 | + #[test] |
| 270 | + fn test_get_cookie_path_success() { |
| 271 | + let test_cases = vec![ |
| 272 | + ( |
| 273 | + None, |
| 274 | + None, |
| 275 | + None, |
| 276 | + env::home_dir().unwrap().join(DEFAULT_BITCOIN_DATADIR).join(".cookie"), |
| 277 | + ), |
| 278 | + ( |
| 279 | + Some((TEST_DATA_DIR, true)), |
| 280 | + Some(Network::Testnet), |
| 281 | + None, |
| 282 | + env::home_dir().unwrap().join(TEST_DATA_DIR).join("testnet").join(".cookie"), |
| 283 | + ), |
| 284 | + ( |
| 285 | + Some((TEST_DATA_DIR, false)), |
| 286 | + Some(Network::Regtest), |
| 287 | + Some(TEST_COOKIE), |
| 288 | + PathBuf::from(TEST_DATA_DIR).join("regtest").join(TEST_COOKIE), |
| 289 | + ), |
| 290 | + ( |
| 291 | + Some((TEST_DATA_DIR, false)), |
| 292 | + Some(Network::Signet), |
| 293 | + None, |
| 294 | + PathBuf::from(TEST_DATA_DIR).join("signet").join(".cookie"), |
| 295 | + ), |
| 296 | + ( |
| 297 | + Some((TEST_DATA_DIR, false)), |
| 298 | + Some(Network::Bitcoin), |
| 299 | + None, |
| 300 | + PathBuf::from(TEST_DATA_DIR).join(".cookie"), |
| 301 | + ), |
| 302 | + ]; |
| 303 | + |
| 304 | + for (data_dir, network, cookie_file, expected_path) in test_cases { |
| 305 | + let path = get_cookie_path(data_dir, network, cookie_file).unwrap(); |
| 306 | + assert_eq!(path, expected_path); |
| 307 | + } |
| 308 | + } |
| 309 | + |
| 310 | + #[test] |
| 311 | + fn test_get_rpc_auth_from_cookie_success() { |
| 312 | + let (username, password) = get_rpc_auth_from_cookie( |
| 313 | + Some((TEST_DATA_DIR, false)), |
| 314 | + Some(Network::Bitcoin), |
| 315 | + Some(TEST_COOKIE), |
| 316 | + ) |
| 317 | + .unwrap(); |
| 318 | + assert_eq!(username, EXPECTED_USER); |
| 319 | + assert_eq!(password, EXPECTED_PASSWORD); |
| 320 | + } |
| 321 | + |
| 322 | + #[test] |
| 323 | + fn test_get_rpc_auth_from_cookie_fail() { |
| 324 | + let result = get_rpc_auth_from_cookie( |
| 325 | + Some((TEST_DATA_DIR, false)), |
| 326 | + Some(Network::Bitcoin), |
| 327 | + Some(TEST_COOKIE_BAD), |
| 328 | + ); |
| 329 | + assert!(result.is_err()); |
| 330 | + } |
| 331 | + |
| 332 | + #[test] |
| 333 | + fn test_parse_env_file_success() { |
| 334 | + let env_file_map = parse_env_file(Some(TEST_ENV_FILE)).unwrap(); |
| 335 | + assert_eq!(env_file_map.get(BITCOIND_RPC_USER_KEY).unwrap(), EXPECTED_USER); |
| 336 | + assert_eq!(env_file_map.get(BITCOIND_RPC_PASSWORD_KEY).unwrap(), EXPECTED_PASSWORD); |
| 337 | + } |
| 338 | + |
| 339 | + #[test] |
| 340 | + fn test_parse_env_file_fail() { |
| 341 | + let env_file_map = parse_env_file(Some(TEST_ENV_FILE_BAD)); |
| 342 | + assert!(env_file_map.is_err()); |
| 343 | + |
| 344 | + // Make sure the test file doesn't exist |
| 345 | + assert!(!Path::new(TEST_ABSENT_FILE).exists()); |
| 346 | + let env_file_map = parse_env_file(Some(TEST_ABSENT_FILE)); |
| 347 | + assert!(env_file_map.is_err()); |
| 348 | + } |
| 349 | + |
| 350 | + #[test] |
| 351 | + fn test_get_rpc_auth_from_env_file_success() { |
| 352 | + let (username, password) = get_rpc_auth_from_env_file(Some(TEST_ENV_FILE)).unwrap(); |
| 353 | + assert_eq!(username, EXPECTED_USER); |
| 354 | + assert_eq!(password, EXPECTED_PASSWORD); |
| 355 | + } |
| 356 | + |
| 357 | + #[test] |
| 358 | + fn test_get_rpc_auth_from_env_file_fail() { |
| 359 | + let rpc_user_and_password = get_rpc_auth_from_env_file(Some(TEST_ABSENT_FILE)); |
| 360 | + assert!(rpc_user_and_password.is_err()); |
| 361 | + } |
| 362 | + |
| 363 | + #[test] |
| 364 | + fn test_get_rpc_auth_from_env_vars_success() { |
| 365 | + env::set_var(BITCOIND_RPC_USER_KEY, EXPECTED_USER); |
| 366 | + env::set_var(BITCOIND_RPC_PASSWORD_KEY, EXPECTED_PASSWORD); |
| 367 | + let (username, password) = get_rpc_auth_from_env_vars().unwrap(); |
| 368 | + assert_eq!(username, EXPECTED_USER); |
| 369 | + assert_eq!(password, EXPECTED_PASSWORD); |
| 370 | + } |
| 371 | +} |
0 commit comments