config_store.rs (19420B)
1 #[cfg(unix)] 2 use std::os::unix::fs::OpenOptionsExt; 3 use std::{ 4 collections::BTreeMap, 5 fs::{self, File, OpenOptions}, 6 io::Write, 7 path::{Path, PathBuf}, 8 time::{SystemTime, UNIX_EPOCH}, 9 }; 10 11 use anyhow::{Context, Result, bail}; 12 use serde::{Deserialize, Serialize}; 13 14 use crate::domain::{Amount, MICRO_IUNA}; 15 16 const CONFIG_FILE_VERSION: u32 = 1; 17 const AMOUNT_UNIT_MICROIUNA: &str = "microiuna"; 18 const LEGACY_AMOUNT_UNIT_PRE_RENAME: &str = concat!("micro", "l", "uun"); 19 pub const DEFAULT_BURN_AMOUNT: Amount = MICRO_IUNA / 10_000; 20 pub const DEFAULT_BURN_FEE: Amount = 1; 21 pub const DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT: u8 = 50; 22 pub const DEFAULT_POW_MINING_WORKERS: u8 = 1; 23 pub const MAX_POW_MINING_WORKERS: u8 = 32; 24 pub const DEFAULT_P2P_BIND_PORT: u16 = 9444; 25 pub const DEFAULT_STRATUM_BIND_PORT: u16 = 3333; 26 pub const DEFAULT_WALLET_ENDPOINT_BIND_PORT: u16 = 18662; 27 28 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 29 pub struct UiConfig { 30 pub setup_complete: bool, 31 #[serde(skip_serializing, default)] 32 pub auth_password_hash: Option<String>, 33 pub mining_enabled: bool, 34 pub pow_mining_enabled: bool, 35 pub pow_mining_workers: u8, 36 pub burn_per_block: Amount, 37 pub burn_fee: Amount, 38 pub recovery_vdf_top_rank_percent: u8, 39 pub keep_track_of_metrics: bool, 40 pub p2p_accept_inbound: bool, 41 pub p2p_bind_port: u16, 42 pub p2p_announce_addr: Option<String>, 43 pub stratum_enabled: bool, 44 pub stratum_bind_port: u16, 45 pub wallet_endpoint_enabled: bool, 46 pub wallet_endpoint_bind_port: u16, 47 pub peers: Vec<String>, 48 pub address_book: BTreeMap<String, String>, 49 } 50 51 impl Default for UiConfig { 52 fn default() -> Self { 53 Self { 54 setup_complete: false, 55 auth_password_hash: None, 56 mining_enabled: false, 57 pow_mining_enabled: false, 58 pow_mining_workers: DEFAULT_POW_MINING_WORKERS, 59 burn_per_block: DEFAULT_BURN_AMOUNT, 60 burn_fee: DEFAULT_BURN_FEE, 61 recovery_vdf_top_rank_percent: DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT, 62 keep_track_of_metrics: false, 63 p2p_accept_inbound: false, 64 p2p_bind_port: DEFAULT_P2P_BIND_PORT, 65 p2p_announce_addr: None, 66 stratum_enabled: false, 67 stratum_bind_port: DEFAULT_STRATUM_BIND_PORT, 68 wallet_endpoint_enabled: false, 69 wallet_endpoint_bind_port: DEFAULT_WALLET_ENDPOINT_BIND_PORT, 70 peers: Vec::new(), 71 address_book: BTreeMap::new(), 72 } 73 } 74 } 75 76 #[derive(Debug, Deserialize, Serialize)] 77 struct ConfigFile { 78 version: u32, 79 #[serde(default)] 80 amount_unit: Option<String>, 81 setup_complete: bool, 82 #[serde(default)] 83 auth_password_hash: Option<String>, 84 #[serde(default)] 85 mining_enabled: Option<bool>, 86 #[serde(default)] 87 pow_mining_enabled: bool, 88 #[serde(default = "default_pow_mining_workers")] 89 pow_mining_workers: u8, 90 #[serde(default)] 91 burn_per_block: Amount, 92 #[serde(default)] 93 burn_fee: Option<Amount>, 94 #[serde(default)] 95 recovery_vdf_top_rank_percent: Option<u8>, 96 #[serde(default)] 97 keep_track_of_metrics: bool, 98 #[serde(default)] 99 p2p_accept_inbound: Option<bool>, 100 #[serde(default = "default_p2p_bind_port")] 101 p2p_bind_port: u16, 102 #[serde(default)] 103 p2p_announce_addr: Option<String>, 104 #[serde(default)] 105 stratum_enabled: bool, 106 #[serde(default = "default_stratum_bind_port")] 107 stratum_bind_port: u16, 108 #[serde(default)] 109 wallet_endpoint_enabled: bool, 110 #[serde(default = "default_wallet_endpoint_bind_port")] 111 wallet_endpoint_bind_port: u16, 112 #[serde(default)] 113 peers: Vec<String>, 114 #[serde(default)] 115 address_book: BTreeMap<String, String>, 116 } 117 118 pub fn load_or_create(path: &Path) -> Result<UiConfig> { 119 if path.exists() { 120 return load(path); 121 } 122 123 let config = UiConfig::default(); 124 save(path, &config)?; 125 Ok(config) 126 } 127 128 pub fn save(path: &Path, config: &UiConfig) -> Result<()> { 129 if let Some(parent) = path.parent() { 130 fs::create_dir_all(parent) 131 .with_context(|| format!("failed to create config directory {}", parent.display()))?; 132 } 133 134 let stored = ConfigFile { 135 version: CONFIG_FILE_VERSION, 136 amount_unit: Some(AMOUNT_UNIT_MICROIUNA.to_string()), 137 setup_complete: config.setup_complete, 138 auth_password_hash: config.auth_password_hash.clone(), 139 mining_enabled: Some(config.mining_enabled), 140 pow_mining_enabled: config.pow_mining_enabled, 141 pow_mining_workers: clamp_pow_mining_workers(config.pow_mining_workers), 142 burn_per_block: config.burn_per_block, 143 burn_fee: Some(config.burn_fee), 144 recovery_vdf_top_rank_percent: Some(config.recovery_vdf_top_rank_percent), 145 keep_track_of_metrics: config.keep_track_of_metrics, 146 p2p_accept_inbound: Some(config.p2p_accept_inbound), 147 p2p_bind_port: config.p2p_bind_port, 148 p2p_announce_addr: config.p2p_announce_addr.clone(), 149 stratum_enabled: config.stratum_enabled, 150 stratum_bind_port: config.stratum_bind_port, 151 wallet_endpoint_enabled: config.wallet_endpoint_enabled, 152 wallet_endpoint_bind_port: config.wallet_endpoint_bind_port, 153 peers: config.peers.clone(), 154 address_book: config.address_book.clone(), 155 }; 156 let mut bytes = 157 serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?; 158 bytes.push(b'\n'); 159 atomic_write_config_file(path, &bytes) 160 } 161 162 fn load(path: &Path) -> Result<UiConfig> { 163 let bytes = 164 fs::read(path).with_context(|| format!("failed to read config file {}", path.display()))?; 165 parse_config_bytes(&bytes, &path.display().to_string()) 166 } 167 168 fn parse_config_bytes(bytes: &[u8], source: &str) -> Result<UiConfig> { 169 let stored: ConfigFile = serde_json::from_slice(bytes) 170 .with_context(|| format!("failed to parse config file {source}"))?; 171 172 if stored.version != CONFIG_FILE_VERSION { 173 bail!( 174 "unsupported config file version {} in {source}", 175 stored.version 176 ); 177 } 178 179 let scale = if matches!( 180 stored.amount_unit.as_deref(), 181 Some(AMOUNT_UNIT_MICROIUNA) | Some(LEGACY_AMOUNT_UNIT_PRE_RENAME) 182 ) { 183 1 184 } else { 185 MICRO_IUNA 186 }; 187 188 let p2p_accept_inbound = stored 189 .p2p_accept_inbound 190 .unwrap_or_else(|| stored.p2p_announce_addr.is_some()); 191 192 Ok(UiConfig { 193 setup_complete: stored.setup_complete, 194 auth_password_hash: stored.auth_password_hash, 195 mining_enabled: stored.mining_enabled.unwrap_or(stored.burn_per_block > 0), 196 pow_mining_enabled: stored.pow_mining_enabled, 197 pow_mining_workers: clamp_pow_mining_workers(stored.pow_mining_workers), 198 burn_per_block: stored.burn_per_block.saturating_mul(scale), 199 burn_fee: stored 200 .burn_fee 201 .map(|fee| fee.saturating_mul(scale)) 202 .unwrap_or(DEFAULT_BURN_FEE), 203 recovery_vdf_top_rank_percent: stored 204 .recovery_vdf_top_rank_percent 205 .unwrap_or(DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT) 206 .min(100), 207 keep_track_of_metrics: stored.keep_track_of_metrics, 208 p2p_accept_inbound, 209 p2p_bind_port: stored.p2p_bind_port, 210 p2p_announce_addr: stored.p2p_announce_addr, 211 stratum_enabled: stored.stratum_enabled, 212 stratum_bind_port: stored.stratum_bind_port, 213 wallet_endpoint_enabled: stored.wallet_endpoint_enabled, 214 wallet_endpoint_bind_port: stored.wallet_endpoint_bind_port, 215 peers: stored.peers, 216 address_book: stored.address_book, 217 }) 218 } 219 220 #[cfg(feature = "fuzzing")] 221 pub fn fuzz_parse_config(bytes: &[u8]) -> Result<UiConfig> { 222 parse_config_bytes(bytes, "<fuzz>") 223 } 224 225 pub fn clamp_pow_mining_workers(workers: u8) -> u8 { 226 workers.clamp(1, MAX_POW_MINING_WORKERS) 227 } 228 229 fn default_pow_mining_workers() -> u8 { 230 DEFAULT_POW_MINING_WORKERS 231 } 232 233 fn default_p2p_bind_port() -> u16 { 234 DEFAULT_P2P_BIND_PORT 235 } 236 237 fn default_stratum_bind_port() -> u16 { 238 DEFAULT_STRATUM_BIND_PORT 239 } 240 241 fn default_wallet_endpoint_bind_port() -> u16 { 242 DEFAULT_WALLET_ENDPOINT_BIND_PORT 243 } 244 245 fn atomic_write_config_file(path: &Path, bytes: &[u8]) -> Result<()> { 246 if let Some(parent) = path.parent() { 247 fs::create_dir_all(parent) 248 .with_context(|| format!("failed to create config directory {}", parent.display()))?; 249 } 250 251 for attempt in 0..16 { 252 let temp_path = temp_file_path(path, attempt); 253 match create_config_file(&temp_path) { 254 Ok(mut file) => { 255 if let Err(error) = write_and_sync(&mut file, bytes) { 256 let _ = fs::remove_file(&temp_path); 257 return Err(error).with_context(|| { 258 format!("failed to write config file {}", path.display()) 259 }); 260 } 261 drop(file); 262 if let Err(error) = fs::rename(&temp_path, path) { 263 let _ = fs::remove_file(&temp_path); 264 return Err(error).with_context(|| { 265 format!("failed to replace config file {}", path.display()) 266 }); 267 } 268 sync_parent_dir(path); 269 return Ok(()); 270 } 271 Err(error) 272 if error 273 .downcast_ref::<std::io::Error>() 274 .is_some_and(|error| error.kind() == std::io::ErrorKind::AlreadyExists) => 275 { 276 continue; 277 } 278 Err(error) => return Err(error), 279 } 280 } 281 282 bail!( 283 "failed to create temporary config file for {}", 284 path.display() 285 ) 286 } 287 288 fn create_config_file(path: &Path) -> Result<File> { 289 let mut options = OpenOptions::new(); 290 options.write(true).create_new(true); 291 #[cfg(unix)] 292 options.mode(0o600); 293 options 294 .open(path) 295 .with_context(|| format!("failed to create config file {}", path.display())) 296 } 297 298 fn write_and_sync(file: &mut File, bytes: &[u8]) -> Result<()> { 299 file.write_all(bytes)?; 300 file.sync_all()?; 301 Ok(()) 302 } 303 304 fn temp_file_path(path: &Path, attempt: u64) -> PathBuf { 305 let file_name = path 306 .file_name() 307 .and_then(|name| name.to_str()) 308 .unwrap_or("config"); 309 let nanos = SystemTime::now() 310 .duration_since(UNIX_EPOCH) 311 .unwrap_or_default() 312 .as_nanos(); 313 path.with_file_name(format!( 314 ".{file_name}.{}.{}.tmp", 315 std::process::id(), 316 nanos.saturating_add(u128::from(attempt)) 317 )) 318 } 319 320 fn sync_parent_dir(path: &Path) { 321 if let Some(parent) = path.parent() { 322 if let Ok(dir) = File::open(parent) { 323 let _ = dir.sync_all(); 324 } 325 } 326 } 327 328 #[cfg(test)] 329 mod tests { 330 use std::fs; 331 332 use tempfile::tempdir; 333 334 use crate::domain::MICRO_IUNA; 335 336 use super::{DEFAULT_BURN_AMOUNT, DEFAULT_BURN_FEE, UiConfig, load_or_create, save}; 337 338 #[test] 339 fn creates_default_config_file() { 340 let dir = tempdir().unwrap(); 341 let path = dir.path().join("config.json"); 342 343 let config = load_or_create(&path).unwrap(); 344 345 assert!(!config.setup_complete); 346 let stored = fs::read_to_string(path).unwrap(); 347 assert!(stored.contains("\"version\": 1")); 348 assert!(stored.contains("\"amount_unit\": \"microiuna\"")); 349 assert!(stored.contains("\"setup_complete\": false")); 350 assert!(stored.contains("\"auth_password_hash\": null")); 351 assert!(stored.contains("\"mining_enabled\": false")); 352 assert!(stored.contains("\"pow_mining_enabled\": false")); 353 assert!(stored.contains("\"pow_mining_workers\": 1")); 354 assert!(stored.contains("\"burn_per_block\": 100")); 355 assert!(stored.contains("\"burn_fee\": 1")); 356 assert!(!stored.contains("\"pow_mine_fee\"")); 357 assert!(!stored.contains("required_burn")); 358 assert!(stored.contains("\"keep_track_of_metrics\": false")); 359 assert!(stored.contains("\"p2p_accept_inbound\": false")); 360 assert!(stored.contains("\"p2p_bind_port\": 9444")); 361 assert!(stored.contains("\"p2p_announce_addr\": null")); 362 assert!(stored.contains("\"stratum_enabled\": false")); 363 assert!(stored.contains("\"stratum_bind_port\": 3333")); 364 assert!(stored.contains("\"wallet_endpoint_enabled\": false")); 365 assert!(stored.contains("\"wallet_endpoint_bind_port\": 18662")); 366 assert!(stored.contains("\"peers\": []")); 367 assert!(stored.contains("\"address_book\": {}")); 368 } 369 370 #[test] 371 fn saves_and_loads_setup_completion() { 372 let dir = tempdir().unwrap(); 373 let path = dir.path().join("config.json"); 374 375 save( 376 &path, 377 &UiConfig { 378 setup_complete: true, 379 auth_password_hash: Some("auth-hash".to_string()), 380 mining_enabled: true, 381 pow_mining_enabled: true, 382 pow_mining_workers: 4, 383 burn_per_block: 50 * MICRO_IUNA, 384 burn_fee: 3 * MICRO_IUNA, 385 keep_track_of_metrics: true, 386 p2p_accept_inbound: true, 387 p2p_bind_port: 9555, 388 p2p_announce_addr: Some("203.0.113.10:9444".to_string()), 389 stratum_enabled: true, 390 stratum_bind_port: 3334, 391 wallet_endpoint_enabled: true, 392 wallet_endpoint_bind_port: 18663, 393 peers: vec!["127.0.0.1:9444".to_string()], 394 address_book: [("iuna-address".to_string(), "Alice".to_string())].into(), 395 ..UiConfig::default() 396 }, 397 ) 398 .unwrap(); 399 let config = load_or_create(&path).unwrap(); 400 401 assert!(config.setup_complete); 402 assert_eq!(config.auth_password_hash.as_deref(), Some("auth-hash")); 403 assert!(config.mining_enabled); 404 assert!(config.pow_mining_enabled); 405 assert_eq!(config.pow_mining_workers, 4); 406 assert_eq!(config.burn_per_block, 50 * MICRO_IUNA); 407 assert_eq!(config.burn_fee, 3 * MICRO_IUNA); 408 assert!(config.keep_track_of_metrics); 409 assert!(config.p2p_accept_inbound); 410 assert_eq!(config.p2p_bind_port, 9555); 411 assert_eq!( 412 config.p2p_announce_addr.as_deref(), 413 Some("203.0.113.10:9444") 414 ); 415 assert!(config.stratum_enabled); 416 assert_eq!(config.stratum_bind_port, 3334); 417 assert!(config.wallet_endpoint_enabled); 418 assert_eq!(config.wallet_endpoint_bind_port, 18663); 419 assert_eq!(config.peers, vec!["127.0.0.1:9444"]); 420 assert_eq!( 421 config.address_book.get("iuna-address"), 422 Some(&"Alice".to_string()) 423 ); 424 } 425 426 #[test] 427 fn stale_atomic_temp_file_does_not_replace_saved_config() { 428 let dir = tempdir().unwrap(); 429 let path = dir.path().join("config.json"); 430 let stale_temp = dir.path().join(".config.json.crash.tmp"); 431 432 save( 433 &path, 434 &UiConfig { 435 setup_complete: true, 436 mining_enabled: true, 437 peers: vec!["127.0.0.1:9444".to_string()], 438 ..UiConfig::default() 439 }, 440 ) 441 .unwrap(); 442 fs::write(&stale_temp, b"{\"version\": 1,").unwrap(); 443 444 let config = load_or_create(&path).unwrap(); 445 446 assert!(config.setup_complete); 447 assert!(config.mining_enabled); 448 assert_eq!(config.peers, vec!["127.0.0.1:9444"]); 449 assert!(stale_temp.exists()); 450 } 451 452 #[test] 453 fn ui_config_json_does_not_expose_password_hash() { 454 let json = serde_json::to_string(&UiConfig { 455 auth_password_hash: Some("secret-password-hash".to_string()), 456 ..UiConfig::default() 457 }) 458 .unwrap(); 459 460 assert!(!json.contains("secret-password-hash")); 461 assert!(!json.contains("auth_password_hash")); 462 } 463 464 #[test] 465 fn loads_old_config_without_burn_rate_as_zero() { 466 let dir = tempdir().unwrap(); 467 let path = dir.path().join("config.json"); 468 fs::write( 469 &path, 470 r#"{ 471 "version": 1, 472 "setup_complete": true, 473 "peers": ["127.0.0.1:9444"] 474 } 475 "#, 476 ) 477 .unwrap(); 478 479 let config = load_or_create(&path).unwrap(); 480 481 assert!(config.setup_complete); 482 assert!(!config.mining_enabled); 483 assert!(!config.pow_mining_enabled); 484 assert_eq!(config.pow_mining_workers, 1); 485 assert_eq!(config.burn_per_block, 0); 486 assert_eq!(config.burn_fee, DEFAULT_BURN_FEE); 487 assert!(!config.keep_track_of_metrics); 488 assert!(!config.p2p_accept_inbound); 489 assert_eq!(config.p2p_bind_port, 9444); 490 assert!(!config.stratum_enabled); 491 assert_eq!(config.stratum_bind_port, 3333); 492 assert!(!config.wallet_endpoint_enabled); 493 assert_eq!(config.wallet_endpoint_bind_port, 18662); 494 assert_eq!(config.peers, vec!["127.0.0.1:9444"]); 495 assert!(config.address_book.is_empty()); 496 } 497 498 #[test] 499 fn default_burn_settings_are_prefilled_while_disabled() { 500 let config = UiConfig::default(); 501 502 assert!(!config.mining_enabled); 503 assert_eq!(config.burn_per_block, DEFAULT_BURN_AMOUNT); 504 assert_eq!(config.burn_fee, DEFAULT_BURN_FEE); 505 assert_eq!(config.burn_per_block, 100); 506 assert_eq!(config.burn_fee, 1); 507 } 508 509 #[test] 510 fn legacy_config_with_announce_address_keeps_public_node_enabled() { 511 let dir = tempdir().unwrap(); 512 let path = dir.path().join("config.json"); 513 fs::write( 514 &path, 515 r#"{ 516 "version": 1, 517 "setup_complete": true, 518 "p2p_announce_addr": "203.0.113.10:9444", 519 "peers": [] 520 } 521 "#, 522 ) 523 .unwrap(); 524 525 let config = load_or_create(&path).unwrap(); 526 527 assert!(config.p2p_accept_inbound); 528 assert_eq!( 529 config.p2p_announce_addr.as_deref(), 530 Some("203.0.113.10:9444") 531 ); 532 } 533 534 #[test] 535 fn loads_old_config_with_burn_rate_as_mining_enabled() { 536 let dir = tempdir().unwrap(); 537 let path = dir.path().join("config.json"); 538 fs::write( 539 &path, 540 r#"{ 541 "version": 1, 542 "setup_complete": true, 543 "burn_per_block": 2, 544 "burn_fee": 1, 545 "peers": [] 546 } 547 "#, 548 ) 549 .unwrap(); 550 551 let config = load_or_create(&path).unwrap(); 552 553 assert!(config.mining_enabled); 554 assert_eq!(config.burn_per_block, 2 * MICRO_IUNA); 555 assert_eq!(config.burn_fee, MICRO_IUNA); 556 } 557 558 #[test] 559 fn loads_renamed_legacy_micro_unit_config_without_rescaling_amounts() { 560 let dir = tempdir().unwrap(); 561 let path = dir.path().join("config.json"); 562 let legacy_unit = format!("micro{}{}", "l", "uun"); 563 fs::write( 564 &path, 565 format!( 566 r#"{{ 567 "version": 1, 568 "amount_unit": "{legacy_unit}", 569 "setup_complete": true, 570 "mining_enabled": true, 571 "burn_per_block": 2000000, 572 "burn_fee": 3, 573 "pow_mine_fee": 5, 574 "peers": [] 575 }} 576 "# 577 ), 578 ) 579 .unwrap(); 580 581 let config = load_or_create(&path).unwrap(); 582 583 assert!(config.mining_enabled); 584 assert_eq!(config.burn_per_block, 2 * MICRO_IUNA); 585 assert_eq!(config.burn_fee, 3); 586 } 587 }