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:
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);