diff --git a/docs/api.md b/docs/api.md index 58cb23b8..47300c06 100644 --- a/docs/api.md +++ b/docs/api.md @@ -85,6 +85,18 @@ table is a summary and may not be exhaustive. | GET | `/miner` | Full state snapshot | | PATCH | `/miner` | Update miner config (e.g. pause) | +### Config + +| Method | Path | Description | +|--------|-----------|---------------------------------------| +| GET | `/config` | Current configuration tree, read-only | + +Read-only for now: this reflects the `MUJINA_POOL_*` environment +variables (see the [top-level README](../README.md) for those). +File-based config layers, writes, and persistence land in later +increments. Pool passwords are never included in the response; +`pool.password_set` only reports whether one is configured. + ### Boards | Method | Path | Description | diff --git a/mujina-miner/src/api/server.rs b/mujina-miner/src/api/server.rs index e7e0a16c..bf6e25a2 100644 --- a/mujina-miner/src/api/server.rs +++ b/mujina-miner/src/api/server.rs @@ -20,6 +20,7 @@ use super::{ v0, }; use crate::api_client::types::MinerTelemetry; +use crate::config::PoolConfig; /// API server configuration. #[derive(Debug, Clone)] @@ -34,6 +35,7 @@ pub(crate) struct SharedState { pub miner_telemetry_rx: watch::Receiver, pub board_registry: Arc>, pub scheduler_cmd_tx: mpsc::Sender, + pub pool_config: Arc>, } impl SharedState { @@ -65,6 +67,7 @@ pub async fn serve( miner_telemetry_rx: watch::Receiver, mut board_reg_rx: mpsc::Receiver, scheduler_cmd_tx: mpsc::Sender, + pool_config: Arc>, ) -> Result<()> { let board_registry = Arc::new(Mutex::new(BoardRegistry::new())); @@ -79,7 +82,12 @@ pub async fn serve( } }); - let app = build_router(miner_telemetry_rx, board_registry, scheduler_cmd_tx); + let app = build_router( + miner_telemetry_rx, + board_registry, + scheduler_cmd_tx, + pool_config, + ); let listener = TcpListener::bind(&config.bind_addr).await?; let actual_addr = listener.local_addr()?; @@ -110,11 +118,13 @@ pub(crate) fn build_router( miner_telemetry_rx: watch::Receiver, board_registry: Arc>, scheduler_cmd_tx: mpsc::Sender, + pool_config: Arc>, ) -> Router { let state = SharedState { miner_telemetry_rx, board_registry, scheduler_cmd_tx, + pool_config, }; let (router, api) = OpenApiRouter::new() @@ -142,7 +152,7 @@ mod tests { use super::*; use crate::api::commands::SchedulerCommand; use crate::api::registry::BoardRegistration; - use crate::api_client::types::{BoardTelemetry, SourceTelemetry}; + use crate::api_client::types::{BoardTelemetry, MinerConfig, SourceTelemetry}; /// Test fixtures returned by the router builder. struct TestFixtures { @@ -158,6 +168,14 @@ mod tests { fn build_test_router( miner_state: MinerTelemetry, board_states: Vec, + ) -> TestFixtures { + build_test_router_with_pool_config(miner_state, board_states, None) + } + + fn build_test_router_with_pool_config( + miner_state: MinerTelemetry, + board_states: Vec, + pool_config: Option, ) -> TestFixtures { let (miner_tx, miner_rx) = watch::channel(miner_state); let (cmd_tx, cmd_rx) = mpsc::channel::(16); @@ -171,7 +189,12 @@ mod tests { } TestFixtures { - router: build_router(miner_rx, Arc::new(Mutex::new(registry)), cmd_tx), + router: build_router( + miner_rx, + Arc::new(Mutex::new(registry)), + cmd_tx, + Arc::new(pool_config), + ), _board_senders: board_senders, _miner_tx: miner_tx, _cmd_rx: cmd_rx, @@ -362,4 +385,40 @@ mod tests { let (status, _body) = get(fixtures.router.clone(), "/api/v0/nope").await; assert_eq!(status, 404); } + + #[tokio::test] + async fn config_returns_null_pool_when_none_configured() { + let fixtures = build_test_router_with_pool_config(MinerTelemetry::default(), vec![], None); + let (status, body) = get(fixtures.router.clone(), "/api/v0/config").await; + assert_eq!(status, 200); + + let config: MinerConfig = serde_json::from_str(&body).unwrap(); + assert!(config.pool.is_none()); + } + + #[tokio::test] + async fn config_exposes_pool_without_password() { + let fixtures = build_test_router_with_pool_config( + MinerTelemetry::default(), + vec![], + Some(PoolConfig { + url: "stratum+tcp://pool.example:3333".into(), + username: "alice.worker1".into(), + password: "hunter2".into(), + password_set: true, + }), + ); + let (status, body) = get(fixtures.router.clone(), "/api/v0/config").await; + assert_eq!(status, 200); + assert!( + !body.contains("hunter2"), + "response must never echo the pool password: {body}" + ); + + let config: MinerConfig = serde_json::from_str(&body).unwrap(); + let pool = config.pool.expect("pool should be present"); + assert_eq!(pool.url, "stratum+tcp://pool.example:3333"); + assert_eq!(pool.username, "alice.worker1"); + assert!(pool.password_set); + } } diff --git a/mujina-miner/src/api/v0.rs b/mujina-miner/src/api/v0.rs index 5778fe5d..a75977c2 100644 --- a/mujina-miner/src/api/v0.rs +++ b/mujina-miner/src/api/v0.rs @@ -16,7 +16,7 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use super::commands::SchedulerCommand; use super::server::SharedState; use crate::api_client::types::{ - BoardTelemetry, MinerPatchRequest, MinerTelemetry, SourceTelemetry, + BoardTelemetry, MinerConfig, MinerPatchRequest, MinerTelemetry, PoolConfig, SourceTelemetry, }; /// Build the v0 API routes with OpenAPI metadata. @@ -24,6 +24,7 @@ pub fn routes() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(health)) .routes(routes!(get_miner, patch_miner)) + .routes(routes!(get_config)) .routes(routes!(get_boards)) .routes(routes!(get_board)) .routes(routes!(get_sources)) @@ -92,6 +93,34 @@ async fn patch_miner( Ok(Json(state.miner_telemetry())) } +/// Return the current configuration tree. +/// +/// Read-only for now; writes and file-based config layers land in +/// later increments. +#[utoipa::path( + get, + path = "/config", + tag = "config", + responses( + (status = OK, description = "Current configuration tree", body = MinerConfig), + ), +)] +async fn get_config(State(state): State) -> Json { + Json(MinerConfig { + pool: (*state.pool_config).as_ref().map(PoolConfig::from), + }) +} + +impl From<&crate::config::PoolConfig> for PoolConfig { + fn from(pool: &crate::config::PoolConfig) -> Self { + Self { + url: pool.url.clone(), + username: pool.username.clone(), + password_set: pool.password_set, + } + } +} + /// Return all connected boards. #[utoipa::path( get, diff --git a/mujina-miner/src/api_client/types.rs b/mujina-miner/src/api_client/types.rs index 6042ba0b..de48ef46 100644 --- a/mujina-miner/src/api_client/types.rs +++ b/mujina-miner/src/api_client/types.rs @@ -92,6 +92,23 @@ pub struct SetFanTargetRequest { pub target_percent: Option, } +/// Configuration tree snapshot, read-only for now. +#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] +pub struct MinerConfig { + pub pool: Option, +} + +/// Pool configuration as exposed over the API. +/// +/// The password is never echoed back; `password_set` only reports +/// whether one is configured. +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct PoolConfig { + pub url: String, + pub username: String, + pub password_set: bool, +} + /// Job source telemetry. #[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema)] pub struct SourceTelemetry { diff --git a/mujina-miner/src/config.rs b/mujina-miner/src/config.rs index 43deb450..d964f791 100644 --- a/mujina-miner/src/config.rs +++ b/mujina-miner/src/config.rs @@ -1,101 +1,126 @@ -//! Configuration management for mujina-miner. +//! Configuration tree for mujina-miner. //! -//! This module handles loading and validating configuration from TOML files, -//! environment variables, and command-line arguments. It supports hot-reload -//! via file watching. +//! Populated from environment variables today. File-based layers, the +//! full source-precedence cascade, and persistence are added in later +//! increments. -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; - -/// Main configuration structure for the miner. -#[derive(Debug, Clone, Deserialize, Serialize)] +/// Root of the miner's configuration tree. +#[derive(Debug, Clone, Default)] pub struct Config { - /// Daemon configuration - pub daemon: DaemonConfig, - - /// Pool configuration - pub pools: Vec, - - /// Hardware configuration - pub hardware: HardwareConfig, - - /// API server configuration - pub api: ApiConfig, + pub pool: Option, } -/// Daemon process configuration. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct DaemonConfig { - /// PID file location - pub pid_file: Option, - - /// Log level - pub log_level: String, - - /// Use systemd notification - #[serde(default)] - pub systemd: bool, +impl Config { + /// Read the configuration tree from environment variables. + pub fn from_env() -> Self { + Self { + pool: PoolConfig::from_env(), + } + } } /// Pool connection configuration. -#[derive(Debug, Clone, Deserialize, Serialize)] +/// +/// Deliberately has no `Serialize` implementation: this type carries a +/// plaintext password, and the API must never be able to echo it back +/// by accident. Handlers that expose pool configuration map this to a +/// redacted view type instead. `Debug` is hand-implemented for the same +/// reason -- the derived version would print the password. +#[derive(Clone)] pub struct PoolConfig { - /// Pool URL (stratum+tcp://...) pub url: String, - - /// Worker name - pub worker: String, - - /// Password (if required) - pub password: Option, - - /// Priority (lower is higher priority) - #[serde(default)] - pub priority: u32, + pub username: String, + pub password: String, + /// Whether `MUJINA_POOL_PASS` was explicitly set. `password` always + /// has a value (falling back to a placeholder), so this is the only + /// reliable signal for "was a password configured." + pub password_set: bool, } -/// Hardware configuration. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct HardwareConfig { - /// Temperature limits - pub temp_limit: f32, - - /// Fan control settings - pub fan_min_rpm: u32, - pub fan_max_rpm: u32, - - /// Power limits - pub power_limit: Option, +impl PoolConfig { + /// Read pool configuration from environment variables. + /// + /// Returns `None` if `MUJINA_POOL_URL` is unset, in which case the + /// caller should fall back to a dummy job source. + /// + /// # Environment Variables + /// + /// - `MUJINA_POOL_URL`: Pool address (e.g. stratum+tcp://host:3333) + /// - `MUJINA_POOL_USER`: Worker username (default: "mujina-testing") + /// - `MUJINA_POOL_PASS`: Worker password (default: "x") + fn from_env() -> Option { + let url = std::env::var("MUJINA_POOL_URL").ok()?; + let username = + std::env::var("MUJINA_POOL_USER").unwrap_or_else(|_| "mujina-testing".to_string()); + let password_env = std::env::var("MUJINA_POOL_PASS").ok(); + let password_set = password_env.is_some(); + let password = password_env.unwrap_or_else(|| "x".to_string()); + + Some(Self { + url, + username, + password, + password_set, + }) + } } -/// API server configuration. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ApiConfig { - /// Listen address - pub listen: String, +impl std::fmt::Debug for PoolConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PoolConfig") + .field("url", &self.url) + .field("username", &self.username) + .field("password", &"[redacted]") + .field("password_set", &self.password_set) + .finish() + } +} - /// Enable TLS - #[serde(default)] - pub tls: bool, +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; - /// TLS certificate path - pub cert_path: Option, + #[test] + #[serial] + fn from_env_returns_none_when_url_unset() { + // SAFETY: Test runs serially, no concurrent env access + unsafe { std::env::remove_var("MUJINA_POOL_URL") }; - /// TLS key path - pub key_path: Option, -} + let config = Config::from_env(); + assert!(config.pool.is_none()); + } -impl Config { - /// Load configuration from the default location. - pub fn load() -> anyhow::Result { - // TODO: Implement config loading from /etc/mujina/mujina.toml - // and ~/.config/mujina/mujina.toml with proper merging - unimplemented!("Config loading not yet implemented") + #[test] + #[serial] + fn from_env_applies_defaults_when_user_and_pass_unset() { + // SAFETY: Test runs serially, no concurrent env access + unsafe { + std::env::set_var("MUJINA_POOL_URL", "stratum+tcp://pool.example:3333"); + std::env::remove_var("MUJINA_POOL_USER"); + std::env::remove_var("MUJINA_POOL_PASS"); + } + + let pool = Config::from_env().pool.expect("pool should be present"); + assert_eq!(pool.url, "stratum+tcp://pool.example:3333"); + assert_eq!(pool.username, "mujina-testing"); + assert_eq!(pool.password, "x"); + assert!(!pool.password_set); } - /// Load configuration from a specific file. - pub fn load_from(_path: &Path) -> anyhow::Result { - // TODO: Implement TOML parsing - unimplemented!("Config loading not yet implemented") + #[test] + #[serial] + fn from_env_reads_username_and_password_when_set() { + // SAFETY: Test runs serially, no concurrent env access + unsafe { + std::env::set_var("MUJINA_POOL_URL", "stratum+tcp://pool.example:3333"); + std::env::set_var("MUJINA_POOL_USER", "alice.worker1"); + std::env::set_var("MUJINA_POOL_PASS", "hunter2"); + } + + let pool = Config::from_env().pool.expect("pool should be present"); + assert_eq!(pool.username, "alice.worker1"); + assert_eq!(pool.password, "hunter2"); + assert!(pool.password_set); } } diff --git a/mujina-miner/src/daemon.rs b/mujina-miner/src/daemon.rs index f14ad225..0eaf1e67 100644 --- a/mujina-miner/src/daemon.rs +++ b/mujina-miner/src/daemon.rs @@ -4,6 +4,7 @@ //! task management, signal handling, and graceful shutdown. use std::env; +use std::sync::Arc; use tokio::signal::unix::{self, SignalKind}; use tokio::sync::{mpsc, watch}; @@ -14,6 +15,7 @@ use crate::tracing::prelude::*; use crate::{ api::{self, ApiConfig, commands::SchedulerCommand}, backplane::Backplane, + config::Config, cpu_miner::CpuMinerConfig, job_source::{ SourceCommand, SourceEvent, @@ -111,23 +113,17 @@ impl Daemon { }); // Create job source (Stratum v1 or Dummy) - // Controlled by environment variables: - // - MUJINA_POOL_URL: Pool address (e.g., stratum+tcp://localhost:3333) - // - MUJINA_POOL_USER: Worker username (optional, defaults to "mujina-testing") - // - MUJINA_POOL_PASS: Worker password (optional, defaults to "x") + let pool_config = Arc::new(Config::from_env().pool); let (source_event_tx, source_event_rx) = mpsc::channel::(100); let (source_cmd_tx, source_cmd_rx) = mpsc::channel(10); - if let Ok(pool_url) = env::var("MUJINA_POOL_URL") { + if let Some(pool) = (*pool_config).clone() { // Use Stratum v1 source - let pool_user = - env::var("MUJINA_POOL_USER").unwrap_or_else(|_| "mujina-testing".to_string()); - let pool_pass = env::var("MUJINA_POOL_PASS").unwrap_or_else(|_| "x".to_string()); - + let pool_url = pool.url; let stratum_config = StratumPoolConfig { url: pool_url.clone(), - username: pool_user, - password: pool_pass, + username: pool.username, + password: pool.password, user_agent: "mujina-miner/0.1.0-alpha".to_string(), }; @@ -252,6 +248,7 @@ impl Daemon { // Start the API server self.tracker.spawn({ let shutdown = self.shutdown.clone(); + let pool_config = pool_config.clone(); async move { // ASCII 'M' (77) + 'U' (85) = 7785 const API_PORT: u16 = 7785; @@ -261,13 +258,14 @@ impl Daemon { Ok(addr) => format!("{addr}:{API_PORT}"), Err(_) => format!("127.0.0.1:{API_PORT}"), }; - let config = ApiConfig { bind_addr }; + let api_config = ApiConfig { bind_addr }; if let Err(e) = api::serve( - config, + api_config, shutdown, miner_telemetry_rx, board_reg_rx, scheduler_cmd_tx, + pool_config, ) .await {