iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 5e022036ad212a0a710fd7e8f4d3da5c7f5cf5a1
parent 6ad8ae67bc8fceb489d896eaf7fd9b692e6ffce9
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Thu, 20 Aug 2026 11:05:15 +0200

Add local Docker testnet startup passwords

Diffstat:
ADockerfile.local-testnet | 24++++++++++++++++++++++++
MREADME.md | 46++++++++++++++++++++++++++++++++++++++++++++++
Adocker-compose.yml | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/http.rs | 12++++++++++++
Msrc/main.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/main_tests.rs | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
6 files changed, 313 insertions(+), 12 deletions(-)

diff --git a/Dockerfile.local-testnet b/Dockerfile.local-testnet @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +FROM rust:1.86.0-bookworm AS builder + +WORKDIR /src/iuna +COPY . . +RUN cargo build --release --locked --bin iuna + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /src/iuna/target/release/iuna /usr/local/bin/iuna +COPY README.md /usr/share/doc/iuna/README.md +COPY LICENSE /usr/share/doc/iuna/LICENSE + +WORKDIR /data + +EXPOSE 18661 9444 3333 + +ENTRYPOINT ["iuna"] diff --git a/README.md b/README.md @@ -109,6 +109,52 @@ On Windows PowerShell: The binary prints a local management URL. Open it and follow setup. +## Optional: Local Docker Testnet + +For local P2P and consensus testing, start a three-node testnet with Docker +Compose: + +```sh +docker compose up --build +``` + +The compose file starts one bootstrap genesis node and two joining nodes on an +isolated Docker network. Management UIs are exposed on: + +- bootstrap: <http://127.0.0.1:18661/> +- node2: <http://127.0.0.1:18662/> +- node3: <http://127.0.0.1:18663/> + +Node3 also exposes Stratum on `127.0.0.1:3333`. P2P ports are mapped to +`19444`, `19445`, and `19446` for local inspection, while nodes announce their +stable Docker-network addresses to each other. + +The local compose file sets `IUNA_WALLET_PASSWORD` for every container. On first +start this configures the management UI password and encrypts the wallet; on +restart it unlocks the encrypted wallet so finalization can continue without UI +login. Override the local defaults from your shell or a `.env` file: + +```sh +IUNA_BOOTSTRAP_WALLET_PASSWORD='change-this-bootstrap-password' \ +IUNA_NODE2_WALLET_PASSWORD='change-this-node2-password' \ +IUNA_NODE3_WALLET_PASSWORD='change-this-node3-password' \ +docker compose up --build +``` + +Do not use compose-file default passwords for public nodes or valuable wallets. + +Stop the network while keeping chain data: + +```sh +docker compose down +``` + +Reset the local testnet volumes and create a fresh genesis: + +```sh +docker compose down -v +``` + ## Optional: Stratum Mining iuna can expose a Stratum V1 endpoint for SHA-256 ASIC miners such as a Bitaxe: diff --git a/docker-compose.yml b/docker-compose.yml @@ -0,0 +1,86 @@ +name: iuna-local-testnet + +x-iuna-node: &iuna-node + build: + context: . + dockerfile: Dockerfile.local-testnet + image: iuna-local-testnet:latest + init: true + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:18661/api/auth/status"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + +services: + bootstrap: + <<: *iuna-node + hostname: iuna-bootstrap + environment: + IUNA_WALLET_PASSWORD: ${IUNA_BOOTSTRAP_WALLET_PASSWORD:-local-testnet-bootstrap-password} + entrypoint: ["/bin/sh", "-c"] + command: + - if [ -s /data/chain.sqlite3 ]; then exec iuna --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.10:9444 --debug; else exec iuna --genesis --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.10:9444 --debug; fi + ports: + - "18661:18661" + - "19444:9444" + volumes: + - bootstrap-data:/data + networks: + iuna-testnet: + ipv4_address: 172.28.0.10 + + node2: + <<: *iuna-node + hostname: iuna-node2 + environment: + IUNA_WALLET_PASSWORD: ${IUNA_NODE2_WALLET_PASSWORD:-local-testnet-node2-password} + entrypoint: ["/bin/sh", "-c"] + command: + - exec iuna --join 172.28.0.10:9444 --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.11:9444 --debug + depends_on: + bootstrap: + condition: service_healthy + ports: + - "18662:18661" + - "19445:9444" + volumes: + - node2-data:/data + networks: + iuna-testnet: + ipv4_address: 172.28.0.11 + + node3: + <<: *iuna-node + hostname: iuna-node3 + environment: + IUNA_WALLET_PASSWORD: ${IUNA_NODE3_WALLET_PASSWORD:-local-testnet-node3-password} + entrypoint: ["/bin/sh", "-c"] + command: + - exec iuna --join 172.28.0.10:9444 --data-dir /data --http 0.0.0.0:18661 --p2p 0.0.0.0:9444 --p2p-announce 172.28.0.12:9444 --stratum 0.0.0.0:3333 --debug + depends_on: + bootstrap: + condition: service_healthy + ports: + - "18663:18661" + - "19446:9444" + - "3333:3333" + volumes: + - node3-data:/data + networks: + iuna-testnet: + ipv4_address: 172.28.0.12 + +networks: + iuna-testnet: + driver: bridge + ipam: + config: + - subnet: 172.28.0.0/24 + +volumes: + bootstrap-data: + node2-data: + node3-data: diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -67,6 +67,18 @@ use wallet::{ required_fee_per_byte_burn, setup_requires_peer, transfer, wallet_setup_json, }; +pub fn validate_management_password(password: &str) -> Result<()> { + auth::validate_password(password) +} + +pub fn hash_management_password(password: &str) -> Result<String> { + auth::hash_password(password) +} + +pub fn verify_management_password(password: &str, encoded: &str) -> Result<bool> { + auth::verify_password(password, encoded) +} + const EXPLORER_LIMIT: usize = 50; const EXPLORER_PAGE_LIMIT: usize = 20; const DATASET_LIMIT: usize = 1_000; diff --git a/src/main.rs b/src/main.rs @@ -39,6 +39,7 @@ const VDF_MEASUREMENT_INITIAL_ROUNDS: u64 = 1_000; const VDF_MEASUREMENT_MAX_ROUNDS: u64 = 10_000_000; const VDF_MEASUREMENT_MIN_ELAPSED: Duration = Duration::from_millis(150); const VDF_PROGRESS_LOG_INTERVAL: Duration = Duration::from_secs(10); +const WALLET_PASSWORD_ENV: &str = "IUNA_WALLET_PASSWORD"; #[tokio::main] async fn main() -> Result<()> { @@ -63,24 +64,31 @@ async fn main() -> Result<()> { ); } let mut ui_config = config_store::load_or_create(&config_path)?; + let startup_wallet_password = startup_wallet_password_from_env()?; let p2p_config_dirty = apply_cli_p2p_config_overrides(&opts, &mut ui_config); let stratum_config_dirty = apply_cli_stratum_config_overrides(&opts, &mut ui_config); - let ui_config_dirty = p2p_config_dirty || stratum_config_dirty; let p2p_announce_addr = configured_p2p_announce_addr(&opts, &ui_config)?; let configured_p2p_addr = configured_p2p_bind_addr(&opts, &ui_config); let configured_stratum_addr = configured_stratum_addr(&opts, &ui_config); let p2p_accept_inbound = ui_config.p2p_accept_inbound; let advertised_p2p_addr = p2p_announce_addr.unwrap_or(configured_p2p_addr); - let wallet_load = load_startup_wallet(&wallet_path)?; - let wallet_address = wallet_load.address().to_string(); if opts.chain_mode == ChainMode::Genesis { ui_config.setup_complete = false; ui_config.mining_enabled = true; ui_config.pow_mining_enabled = false; ui_config.burn_per_block = GENESIS_INITIAL_BURN_PER_BLOCK; ui_config.burn_fee = GENESIS_INITIAL_BURN_FEE; - config_store::save(&config_path, &ui_config)?; - } else if ui_config_dirty { + } + let auth_config_dirty = apply_startup_wallet_password_config( + &config_path, + &mut ui_config, + startup_wallet_password.as_deref(), + )?; + let wallet_load = load_startup_wallet(&wallet_path, startup_wallet_password.as_deref())?; + let wallet_address = wallet_load.address().to_string(); + let ui_config_dirty = + opts.chain_mode == ChainMode::Genesis || p2p_config_dirty || stratum_config_dirty; + if ui_config_dirty || auth_config_dirty { config_store::save(&config_path, &ui_config)?; } let ledger = @@ -245,7 +253,21 @@ impl StartupWallet { } } -fn load_startup_wallet(wallet_path: &Path) -> Result<StartupWallet> { +fn load_startup_wallet( + wallet_path: &Path, + startup_wallet_password: Option<&str>, +) -> Result<StartupWallet> { + if let Some(password) = startup_wallet_password { + if wallet_path.exists() { + wallet_store::encrypt_existing_with_password(wallet_path, password)?; + let wallet = wallet_store::load_with_password(wallet_path, password)?; + return Ok(StartupWallet::Unlocked { wallet }); + } + let (wallet, _) = + wallet_store::replace_with_generated_seed_phrase_encrypted(wallet_path, password)?; + return Ok(StartupWallet::Unlocked { wallet }); + } + match wallet_store::load_or_create(wallet_path) { Ok(wallet) => Ok(StartupWallet::Unlocked { wallet }), Err(error) => { @@ -263,6 +285,39 @@ fn load_startup_wallet(wallet_path: &Path) -> Result<StartupWallet> { } } +fn startup_wallet_password_from_env() -> Result<Option<String>> { + let Some(password) = std::env::var_os(WALLET_PASSWORD_ENV) else { + return Ok(None); + }; + let password = password + .into_string() + .map_err(|_| anyhow::anyhow!("{WALLET_PASSWORD_ENV} must be valid UTF-8"))?; + http::validate_management_password(&password) + .with_context(|| format!("{WALLET_PASSWORD_ENV} is not a valid wallet password"))?; + Ok(Some(password)) +} + +fn apply_startup_wallet_password_config( + config_path: &Path, + ui_config: &mut config_store::UiConfig, + password: Option<&str>, +) -> Result<bool> { + let Some(password) = password else { + return Ok(false); + }; + let Some(existing_hash) = ui_config.auth_password_hash.as_deref() else { + ui_config.auth_password_hash = Some(http::hash_management_password(password)?); + return Ok(true); + }; + if !http::verify_management_password(password, existing_hash)? { + bail!( + "{WALLET_PASSWORD_ENV} does not match the configured management UI and wallet password in {}", + config_path.display() + ); + } + Ok(false) +} + fn format_iuna(amount: Amount) -> String { let whole = amount / MICRO_IUNA; let fractional = amount % MICRO_IUNA; diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -2,7 +2,9 @@ use std::{collections::BTreeMap, sync::Arc, time::Duration}; use iuna::{ adapters::{ - chain_store::SqliteChainStore, config_store::UiConfig, ui_data_store::SqliteUiDataStore, + chain_store::SqliteChainStore, + config_store::{self, UiConfig}, + ui_data_store::SqliteUiDataStore, wallet_store, }, app::{DEFAULT_BURN_PER_BLOCK, MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID, NodeCore}, @@ -15,10 +17,11 @@ use tokio::sync::Mutex; use super::{ ChainMode, CliOptions, GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK, StartupWallet, apply_cli_p2p_config_overrides, apply_cli_stratum_config_overrides, - configured_p2p_announce_addr, configured_p2p_bind_addr, configured_stratum_addr, - extrapolate_vdf_rounds, help_text, initial_burn_fee, initial_burn_per_block, initialize_ledger, - load_startup_wallet, measure_vdf_rounds, persist_chain_snapshot, project_ui_data_store, - run_chain_persistence_with_interval, validate_wallet_for_mode, + apply_startup_wallet_password_config, configured_p2p_announce_addr, configured_p2p_bind_addr, + configured_stratum_addr, extrapolate_vdf_rounds, help_text, initial_burn_fee, + initial_burn_per_block, initialize_ledger, load_startup_wallet, measure_vdf_rounds, + persist_chain_snapshot, project_ui_data_store, run_chain_persistence_with_interval, + validate_wallet_for_mode, }; fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> { @@ -117,7 +120,7 @@ fn encrypted_startup_wallet_loads_as_locked_metadata() { wallet_store::replace_with_generated_seed_phrase_encrypted(&path, "password-123456") .unwrap(); - let startup = load_startup_wallet(&path).unwrap(); + let startup = load_startup_wallet(&path, None).unwrap(); match startup { StartupWallet::Locked { address } => assert_eq!(address, wallet.address()), @@ -126,6 +129,81 @@ fn encrypted_startup_wallet_loads_as_locked_metadata() { } #[test] +fn startup_wallet_password_encrypts_new_wallet_and_configures_auth() { + let dir = tempdir().unwrap(); + let wallet_path = dir.path().join("wallet.json"); + let config_path = dir.path().join("config.json"); + let password = "local-testnet-password"; + let mut config = UiConfig::default(); + + let dirty = + apply_startup_wallet_password_config(&config_path, &mut config, Some(password)).unwrap(); + config_store::save(&config_path, &config).unwrap(); + let startup = load_startup_wallet(&wallet_path, Some(password)).unwrap(); + + assert!(dirty); + assert!(config.auth_password_hash.is_some()); + assert!( + wallet_store::metadata(&wallet_path) + .unwrap() + .unwrap() + .encrypted + ); + match startup { + StartupWallet::Unlocked { wallet } => { + let reloaded = wallet_store::load_with_password(&wallet_path, password).unwrap(); + assert_eq!(reloaded.address(), wallet.address()); + } + StartupWallet::Locked { .. } => panic!("env password should unlock startup wallet"), + } +} + +#[test] +fn startup_wallet_password_unlocks_existing_encrypted_wallet() { + let dir = tempdir().unwrap(); + let wallet_path = dir.path().join("wallet.json"); + let password = "local-testnet-password"; + let (wallet, _) = + wallet_store::replace_with_generated_seed_phrase_encrypted(&wallet_path, password).unwrap(); + + let startup = load_startup_wallet(&wallet_path, Some(password)).unwrap(); + + match startup { + StartupWallet::Unlocked { wallet: startup } => { + assert_eq!(startup.address(), wallet.address()); + } + StartupWallet::Locked { .. } => panic!("env password should unlock encrypted wallet"), + } +} + +#[test] +fn startup_wallet_password_mismatch_does_not_encrypt_plaintext_wallet() { + let dir = tempdir().unwrap(); + let wallet_path = dir.path().join("wallet.json"); + let config_path = dir.path().join("config.json"); + let (_wallet, _seed) = wallet_store::replace_with_generated_seed_phrase(&wallet_path).unwrap(); + let mut config = UiConfig { + auth_password_hash: Some( + iuna::adapters::http::hash_management_password("correct-password").unwrap(), + ), + ..UiConfig::default() + }; + + let error = + apply_startup_wallet_password_config(&config_path, &mut config, Some("wrong-password")) + .unwrap_err(); + + assert!(error.to_string().contains("does not match")); + assert!( + !wallet_store::metadata(&wallet_path) + .unwrap() + .unwrap() + .encrypted + ); + assert!(wallet_store::load_or_create(&wallet_path).is_ok()); +} + +#[test] fn no_args_starts_setup_mode() { let opts = parse(&[]).unwrap().unwrap(); assert_eq!(opts.chain_mode, ChainMode::Setup);