diff --git a/docs/api.md b/docs/api.md index 58cb23b8..3be405fd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -69,9 +69,9 @@ Percentage fields (`percent`, `target_percent`) are integers ### Naming -Boards and sources are identified by a URL-friendly `name` field -(e.g. `bitaxe-e2f56f9b`). These names appear in URL paths for -single-resource endpoints like `/api/v0/boards/{name}`. +Boards and sources are identified by a URL-friendly `name` +field (e.g. `bitaxe-e2f56f9b`). These names appear in URL paths +for single-resource endpoints like `/api/v0/boards/{name}`. ## Endpoints @@ -92,12 +92,38 @@ table is a summary and may not be exhaustive. | GET | `/boards` | List connected boards | | GET | `/boards/{name}` | Single board detail | +### Config + +| Method | Path | Description | +|--------|-----------|--------------------------| +| GET | `/config` | Full configuration tree | + +Includes each pool's password in plaintext -- there is no +redaction. The API has no authentication, so anyone who can +reach it can read it. + ### Sources -| Method | Path | Description | -|--------|-------------------|----------------------| -| GET | `/sources` | List job sources | -| GET | `/sources/{name}` | Single source detail | +| Method | Path | Description | +|--------|-------------------|-------------------------------| +| GET | `/sources` | List configured job sources | +| GET | `/sources/{name}` | Single source detail | + +Same data as the `sources` field of `/config`, addressable by +`name`. Each entry carries a `kind` field identifying what kind +of source it is; `"stratum_v1"` is the only kind today, with +fields matching a Stratum pool (`url`, `username`, `password`). +"Source" is the term the rest of the codebase already uses for +this concept, and it covers more than pool connections -- the +dummy source used when none is configured, and, over time, +other protocols (Stratum v2 and beyond). `kind` exists so a +client can tell sources apart, and so adding a source kind +later is additive rather than a breaking change to this +endpoint. + +A source's `name` is assigned automatically today (`a`, `b`, +`c`, ... by position) since there's no way to name one +explicitly yet. ### Health @@ -109,8 +135,15 @@ All paths are relative to `/api/v0`. ## Types -The request and response types are defined in Rust in the +Most request and response types are defined in Rust in the `api_client::types` module (`mujina-miner/src/api_client/types.rs`). These types are the shared contract between the server and its clients (CLI, TUI). The OpenAPI schema is derived from them automatically. + +`/config` and `/sources` are the exception: they serialize +`config::Config`, `config::SourceConfig` +(`mujina-miner/src/config.rs`), and +`stratum_v1::StratumV1PoolConfig` +(`mujina-miner/src/stratum_v1/client.rs`) directly, rather than a +separate view type. diff --git a/mujina-miner/src/api/server.rs b/mujina-miner/src/api/server.rs index e7e0a16c..e44927c0 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::Config; /// 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 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, + config_tree: Config, ) -> 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, + Arc::new(config_tree), + ); 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, + config: Arc, ) -> Router { let state = SharedState { miner_telemetry_rx, board_registry, scheduler_cmd_tx, + config, }; let (router, api) = OpenApiRouter::new() @@ -143,6 +153,8 @@ mod tests { use crate::api::commands::SchedulerCommand; use crate::api::registry::BoardRegistration; use crate::api_client::types::{BoardTelemetry, SourceTelemetry}; + use crate::config::{SourceConfig, SourceKind}; + use crate::stratum_v1::StratumV1PoolConfig; /// Test fixtures returned by the router builder. struct TestFixtures { @@ -158,6 +170,7 @@ mod tests { fn build_test_router( miner_state: MinerTelemetry, board_states: Vec, + config: Config, ) -> TestFixtures { let (miner_tx, miner_rx) = watch::channel(miner_state); let (cmd_tx, cmd_rx) = mpsc::channel::(16); @@ -171,7 +184,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(config), + ), _board_senders: board_senders, _miner_tx: miner_tx, _cmd_rx: cmd_rx, @@ -191,7 +209,7 @@ mod tests { #[tokio::test] async fn health_returns_ok() { - let fixtures = build_test_router(MinerTelemetry::default(), vec![]); + let fixtures = build_test_router(MinerTelemetry::default(), vec![], Config::default()); let (status, body) = get(fixtures.router.clone(), "/api/v0/health").await; assert_eq!(status, 200); assert_eq!(body, "OK"); @@ -215,7 +233,7 @@ mod tests { model: "TestModel".into(), ..Default::default() }; - let fixtures = build_test_router(miner_state, vec![board]); + let fixtures = build_test_router(miner_state, vec![board], Config::default()); let (status, body) = get(fixtures.router.clone(), "/api/v0/miner").await; assert_eq!(status, 200); @@ -244,7 +262,7 @@ mod tests { ..Default::default() }, ]; - let fixtures = build_test_router(MinerTelemetry::default(), boards); + let fixtures = build_test_router(MinerTelemetry::default(), boards, Config::default()); let (status, body) = get(fixtures.router.clone(), "/api/v0/boards").await; assert_eq!(status, 200); @@ -263,7 +281,7 @@ mod tests { serial: Some("abc123".into()), ..Default::default() }; - let fixtures = build_test_router(MinerTelemetry::default(), vec![board]); + let fixtures = build_test_router(MinerTelemetry::default(), vec![board], Config::default()); let (status, body) = get(fixtures.router.clone(), "/api/v0/boards/bitaxe-abc123").await; assert_eq!(status, 200); @@ -275,90 +293,107 @@ mod tests { #[tokio::test] async fn board_by_name_returns_404_when_missing() { - let fixtures = build_test_router(MinerTelemetry::default(), vec![]); + let fixtures = build_test_router(MinerTelemetry::default(), vec![], Config::default()); let (status, _body) = get(fixtures.router.clone(), "/api/v0/boards/nonexistent").await; assert_eq!(status, 404); } #[tokio::test] - async fn sources_returns_list() { - let miner_state = MinerTelemetry { - sources: vec![ - SourceTelemetry { - name: "pool-a".into(), - url: Some("stratum+tcp://a:3333".into()), - ..Default::default() - }, - SourceTelemetry { - name: "pool-b".into(), - url: None, + async fn config_returns_configured_sources() { + let config = Config { + sources: vec![SourceConfig { + name: "a".into(), + kind: SourceKind::StratumV1(StratumV1PoolConfig { + url: "stratum+tcp://pool.example:3333".into(), + username: "alice.worker1".into(), + password: Some("hunter2".into()), ..Default::default() - }, - ], - ..Default::default() + }), + }], }; - let fixtures = build_test_router(miner_state, vec![]); + let fixtures = build_test_router(MinerTelemetry::default(), vec![], config); - let (status, body) = get(fixtures.router.clone(), "/api/v0/sources").await; + let (status, body) = get(fixtures.router.clone(), "/api/v0/config").await; assert_eq!(status, 200); - let sources: Vec = serde_json::from_str(&body).unwrap(); - assert_eq!(sources.len(), 2); - assert_eq!(sources[0].name, "pool-a"); - assert_eq!(sources[0].url.as_deref(), Some("stratum+tcp://a:3333")); - assert_eq!(sources[1].name, "pool-b"); - assert_eq!(sources[1].url, None); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(json["sources"][0]["name"], "a"); + assert_eq!(json["sources"][0]["kind"], "stratum_v1"); + assert_eq!(json["sources"][0]["url"], "stratum+tcp://pool.example:3333"); + assert_eq!(json["sources"][0]["username"], "alice.worker1"); + assert_eq!(json["sources"][0]["password"], "hunter2"); } #[tokio::test] - async fn source_by_name_returns_match() { - let miner_state = MinerTelemetry { - sources: vec![SourceTelemetry { - name: "my-pool".into(), - url: Some("stratum+tcp://pool:3333".into()), - ..Default::default() + async fn sources_returns_configured_sources() { + let config = Config { + sources: vec![SourceConfig { + name: "a".into(), + kind: SourceKind::StratumV1(StratumV1PoolConfig { + url: "stratum+tcp://pool.example:3333".into(), + username: "alice.worker1".into(), + password: None, + ..Default::default() + }), }], - ..Default::default() }; - let fixtures = build_test_router(miner_state, vec![]); + let fixtures = build_test_router(MinerTelemetry::default(), vec![], config); - let (status, body) = get(fixtures.router.clone(), "/api/v0/sources/my-pool").await; + let (status, body) = get(fixtures.router.clone(), "/api/v0/sources").await; assert_eq!(status, 200); - let source: SourceTelemetry = serde_json::from_str(&body).unwrap(); - assert_eq!(source.name, "my-pool"); - assert_eq!(source.url.as_deref(), Some("stratum+tcp://pool:3333")); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let sources = json.as_array().unwrap(); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0]["name"], "a"); + assert_eq!(sources[0]["kind"], "stratum_v1"); + assert_eq!(sources[0]["url"], "stratum+tcp://pool.example:3333"); + assert_eq!(sources[0]["username"], "alice.worker1"); + assert!(sources[0]["password"].is_null()); } #[tokio::test] - async fn source_by_name_returns_404_when_missing() { - let fixtures = build_test_router(MinerTelemetry::default(), vec![]); - let (status, _body) = get(fixtures.router.clone(), "/api/v0/sources/nonexistent").await; - assert_eq!(status, 404); + async fn sources_returns_empty_when_no_pools_configured() { + let fixtures = build_test_router(MinerTelemetry::default(), vec![], Config::default()); + + let (status, body) = get(fixtures.router.clone(), "/api/v0/sources").await; + assert_eq!(status, 200); + assert_eq!(body, "[]"); } #[tokio::test] - async fn source_difficulty_serializes_as_f64() { - let miner_state = MinerTelemetry { - sources: vec![SourceTelemetry { - name: "pool".into(), - difficulty: Some(2048.5), - ..Default::default() + async fn source_by_name_returns_match() { + let config = Config { + sources: vec![SourceConfig { + name: "a".into(), + kind: SourceKind::StratumV1(StratumV1PoolConfig { + url: "stratum+tcp://pool.example:3333".into(), + username: "alice.worker1".into(), + password: None, + ..Default::default() + }), }], - ..Default::default() }; - let fixtures = build_test_router(miner_state, vec![]); + let fixtures = build_test_router(MinerTelemetry::default(), vec![], config); - let (status, body) = get(fixtures.router.clone(), "/api/v0/sources/pool").await; + let (status, body) = get(fixtures.router.clone(), "/api/v0/sources/a").await; assert_eq!(status, 200); - let source: SourceTelemetry = serde_json::from_str(&body).unwrap(); - assert_eq!(source.difficulty, Some(2048.5)); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(json["name"], "a"); + assert_eq!(json["url"], "stratum+tcp://pool.example:3333"); + } + + #[tokio::test] + async fn source_by_name_returns_404_when_missing() { + let fixtures = build_test_router(MinerTelemetry::default(), vec![], Config::default()); + let (status, _body) = get(fixtures.router.clone(), "/api/v0/sources/nonexistent").await; + assert_eq!(status, 404); } #[tokio::test] async fn unknown_route_returns_404() { - let fixtures = build_test_router(MinerTelemetry::default(), vec![]); + let fixtures = build_test_router(MinerTelemetry::default(), vec![], Config::default()); let (status, _body) = get(fixtures.router.clone(), "/api/v0/nope").await; assert_eq!(status, 404); } diff --git a/mujina-miner/src/api/v0.rs b/mujina-miner/src/api/v0.rs index 5778fe5d..8651679d 100644 --- a/mujina-miner/src/api/v0.rs +++ b/mujina-miner/src/api/v0.rs @@ -15,9 +15,8 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use super::commands::SchedulerCommand; use super::server::SharedState; -use crate::api_client::types::{ - BoardTelemetry, MinerPatchRequest, MinerTelemetry, SourceTelemetry, -}; +use crate::api_client::types::{BoardTelemetry, MinerPatchRequest, MinerTelemetry}; +use crate::config::{Config, SourceConfig}; /// Build the v0 API routes with OpenAPI metadata. pub fn routes() -> OpenApiRouter { @@ -26,6 +25,7 @@ pub fn routes() -> OpenApiRouter { .routes(routes!(get_miner, patch_miner)) .routes(routes!(get_boards)) .routes(routes!(get_board)) + .routes(routes!(get_config)) .routes(routes!(get_sources)) .routes(routes!(get_source)) } @@ -139,20 +139,33 @@ async fn get_board( .ok_or(StatusCode::NOT_FOUND) } -/// Return all registered job sources. +/// Return the full configuration tree. +#[utoipa::path( + get, + path = "/config", + tag = "config", + responses( + (status = OK, description = "Current configuration", body = Config), + ), +)] +async fn get_config(State(state): State) -> Json { + Json((*state.config).clone()) +} + +/// Return the configured job sources. #[utoipa::path( get, path = "/sources", tag = "sources", responses( - (status = OK, description = "List of job sources", body = Vec), + (status = OK, description = "List of configured job sources", body = Vec), ), )] -async fn get_sources(State(state): State) -> Json> { - Json(state.miner_telemetry_rx.borrow().sources.clone()) +async fn get_sources(State(state): State) -> Json> { + Json(state.config.sources.clone()) } -/// Return a single source by name, or 404 if not found. +/// Return a single configured job source by name, or 404 if not found. #[utoipa::path( get, path = "/sources/{name}", @@ -161,17 +174,16 @@ async fn get_sources(State(state): State) -> Json, Path(name): Path, -) -> Result, StatusCode> { +) -> Result, StatusCode> { state - .miner_telemetry_rx - .borrow() + .config .sources .iter() .find(|s| s.name == name) diff --git a/mujina-miner/src/config.rs b/mujina-miner/src/config.rs index 43deb450..f0e6c031 100644 --- a/mujina-miner/src/config.rs +++ b/mujina-miner/src/config.rs @@ -1,101 +1,142 @@ -//! 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. -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +use serde::Serialize; +use utoipa::ToSchema; -/// Main configuration structure for the miner. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Config { - /// Daemon configuration - pub daemon: DaemonConfig, - - /// Pool configuration - pub pools: Vec, - - /// Hardware configuration - pub hardware: HardwareConfig, +use crate::stratum_v1::StratumV1PoolConfig; - /// API server configuration - pub api: ApiConfig, +/// Root of the miner's configuration tree. +#[derive(Debug, Clone, Default, Serialize, ToSchema)] +pub struct Config { + pub sources: Vec, } -/// 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 { + sources: stratum_v1_pool_from_env() + .into_iter() + .enumerate() + .map(|(i, pool)| SourceConfig { + name: generated_name(i), + kind: SourceKind::StratumV1(pool), + }) + .collect(), + } + } } -/// Pool connection configuration. -#[derive(Debug, Clone, Deserialize, Serialize)] -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, +/// A configured job source. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SourceConfig { + /// URL-friendly identifier, unique among sources. + pub name: String, + #[serde(flatten)] + pub kind: SourceKind, } -/// 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, +/// What kind of job source this is. +/// +/// Tagged by `kind` rather than assuming every source is a pool. +/// "Source" is already the vocabulary the rest of the codebase uses +/// for this concept (the `job_source` module, `SourceRegistration`, +/// `SourceEvent`), and it covers more than pool connections: the dummy +/// source used when none is configured, and, over time, other +/// protocols (Stratum v2 and whatever comes after). `StratumV1` is the +/// only variant today; adding a source kind means adding a variant +/// here, not reshaping this one. +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SourceKind { + StratumV1(StratumV1PoolConfig), +} - /// Power limits - pub power_limit: Option, +/// Derive a name for a source that has none configured explicitly. +/// +/// Assigns letters by position (`a`, `b`, `c`, ...). Good enough while +/// naming can't yet be set explicitly (e.g. per-source in a config file +/// or env var); once it can, that name should take precedence and this +/// should only apply to whatever's left unnamed. +fn generated_name(index: usize) -> String { + char::from(b'a' + (index as u8 % 26)).to_string() } -/// API server configuration. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ApiConfig { - /// Listen address - pub listen: String, +/// Read Stratum v1 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, if the pool requires one +fn stratum_v1_pool_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 = std::env::var("MUJINA_POOL_PASS").ok(); + + Some(StratumV1PoolConfig { + url, + username, + password, + ..Default::default() + }) +} - /// 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.sources.is_empty()); + } -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_defaults_username_and_omits_password_when_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 sources = Config::from_env().sources; + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].name, "a"); + let SourceKind::StratumV1(pool) = &sources[0].kind; + assert_eq!(pool.url, "stratum+tcp://pool.example:3333"); + assert_eq!(pool.username, "mujina-testing"); + assert_eq!(pool.password, None); } - /// 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 sources = Config::from_env().sources; + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].name, "a"); + let SourceKind::StratumV1(pool) = &sources[0].kind; + assert_eq!(pool.username, "alice.worker1"); + assert_eq!(pool.password, Some("hunter2".to_string())); } } diff --git a/mujina-miner/src/daemon.rs b/mujina-miner/src/daemon.rs index f14ad225..3d74482c 100644 --- a/mujina-miner/src/daemon.rs +++ b/mujina-miner/src/daemon.rs @@ -14,6 +14,7 @@ use crate::tracing::prelude::*; use crate::{ api::{self, ApiConfig, commands::SchedulerCommand}, backplane::Backplane, + config::{Config, SourceKind}, cpu_miner::CpuMinerConfig, job_source::{ SourceCommand, SourceEvent, @@ -22,7 +23,7 @@ use crate::{ stratum_v1::StratumV1Source, }, scheduler::{self, SourceRegistration, ThreadRegistration}, - stratum_v1::{PoolConfig as StratumPoolConfig, TcpConnector}, + stratum_v1::TcpConnector, transport::{CpuDeviceInfo, TransportEvent, UsbTransport, cpu as cpu_transport}, }; @@ -111,25 +112,20 @@ 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 config = Config::from_env(); + let pool_config = config + .sources + .iter() + .map(|s| match &s.kind { + SourceKind::StratumV1(pool) => pool.clone(), + }) + .next(); 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(stratum_config) = pool_config { // 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 stratum_config = StratumPoolConfig { - url: pool_url.clone(), - username: pool_user, - password: pool_pass, - user_agent: "mujina-miner/0.1.0-alpha".to_string(), - }; + let pool_url = stratum_config.url.clone(); // Optionally wrap with ForcedRateSource for testing if let Some(forced_rate_config) = ForcedRateConfig::from_env() { @@ -261,13 +257,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, + config, ) .await { diff --git a/mujina-miner/src/job_source/stratum_v1.rs b/mujina-miner/src/job_source/stratum_v1.rs index 7c0c9231..56fe386f 100644 --- a/mujina-miner/src/job_source/stratum_v1.rs +++ b/mujina-miner/src/job_source/stratum_v1.rs @@ -15,7 +15,7 @@ use tokio::time::{self, Instant}; use tokio_util::sync::CancellationToken; use crate::stratum_v1::{ - ClientCommand, ClientEvent, Connector, JobNotification, PoolConfig, StratumV1Client, + ClientCommand, ClientEvent, Connector, JobNotification, StratumV1Client, StratumV1PoolConfig, }; use crate::tracing::prelude::*; use crate::types::{Difficulty, HashRate, ShareRate}; @@ -121,7 +121,7 @@ enum ConnectOutcome { /// messages to JobTemplates and outgoing Share submissions to Stratum format. pub struct StratumV1Source { /// Pool configuration - config: PoolConfig, + config: StratumV1PoolConfig, /// Where to send events to scheduler event_tx: mpsc::Sender, @@ -170,7 +170,7 @@ struct ProtocolState { impl StratumV1Source { /// Create a new Stratum v1 source. pub fn new( - config: PoolConfig, + config: StratumV1PoolConfig, command_rx: mpsc::Receiver, event_tx: mpsc::Sender, shutdown: CancellationToken, @@ -758,10 +758,10 @@ mod tests { let (_command_tx, command_rx) = mpsc::channel(10); let shutdown = CancellationToken::new(); - let config = PoolConfig { + let config = StratumV1PoolConfig { url: "stratum+tcp://test:3333".to_string(), username: "testworker".to_string(), - password: "x".to_string(), + password: Some("x".to_string()), user_agent: "test".to_string(), ..Default::default() }; @@ -1186,7 +1186,7 @@ mod tests { let (event_tx, _event_rx) = mpsc::channel(10); let (_command_tx, command_rx) = mpsc::channel(10); let shutdown = CancellationToken::new(); - let config = PoolConfig { + let config = StratumV1PoolConfig { url: "stratum+tcp://test:3333".to_string(), ..Default::default() }; @@ -1245,7 +1245,7 @@ mod tests { fn throttle_test_source() -> StratumV1Source { let (event_tx, _event_rx) = mpsc::channel(10); let (_command_tx, command_rx) = mpsc::channel(10); - let config = PoolConfig { + let config = StratumV1PoolConfig { url: "stratum+tcp://test:3333".to_string(), ..Default::default() }; @@ -1356,7 +1356,7 @@ mod tests { let (event_tx, _event_rx) = mpsc::channel(10); let (_command_tx, command_rx) = mpsc::channel(10); let shutdown = CancellationToken::new(); - let config = PoolConfig { + let config = StratumV1PoolConfig { url: "stratum+tcp://test:3333".to_string(), ..Default::default() }; @@ -1389,7 +1389,7 @@ mod tests { let (event_tx, _event_rx) = mpsc::channel(10); let (_command_tx, command_rx) = mpsc::channel(10); let shutdown = CancellationToken::new(); - let config = PoolConfig { + let config = StratumV1PoolConfig { url: "stratum+tcp://test:3333".to_string(), ..Default::default() }; @@ -1424,7 +1424,7 @@ mod tests { let (event_tx, _event_rx) = mpsc::channel(10); let (_command_tx, command_rx) = mpsc::channel(10); let shutdown = CancellationToken::new(); - let config = PoolConfig { + let config = StratumV1PoolConfig { url: "stratum+tcp://test:3333".to_string(), ..Default::default() }; @@ -1587,10 +1587,10 @@ mod tests { let shutdown = CancellationToken::new(); let (mock_tx, mock_rx) = mpsc::channel(10); - let config = PoolConfig { + let config = StratumV1PoolConfig { url: "stratum+tcp://test:3333".to_string(), username: "testworker".to_string(), - password: "x".to_string(), + password: Some("x".to_string()), user_agent: "test".to_string(), ..Default::default() }; diff --git a/mujina-miner/src/stratum_v1/client.rs b/mujina-miner/src/stratum_v1/client.rs index a34e7a31..e77f9897 100644 --- a/mujina-miner/src/stratum_v1/client.rs +++ b/mujina-miner/src/stratum_v1/client.rs @@ -5,6 +5,9 @@ use std::time::Duration; +use serde::Serialize; +use utoipa::ToSchema; + use super::connection::{Connection, Transport}; use super::error::{StratumError, StratumResult}; use super::messages::{ClientCommand, ClientEvent, JsonRpcMessage, SubmitParams}; @@ -13,32 +16,50 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; /// Pool connection configuration. -#[derive(Debug, Clone)] -pub struct PoolConfig { +/// +/// `Debug` is hand-implemented to redact the password, since `Debug` +/// output can end up in trace logs. `Serialize` is derived and does +/// include it: the config API is how an operator inspects their own +/// pool configuration. +#[derive(Clone, Serialize, ToSchema)] +pub struct StratumV1PoolConfig { /// Pool URL (stratum+tcp://host:port or host:port) pub url: String, /// Worker username pub username: String, - /// Worker password - pub password: String, + /// Worker password, if the pool requires one. `None` means no + /// password was configured, distinct from an explicitly empty one; + /// callers that need a wire value default to "x" at send time. + pub password: Option, /// User agent string pub user_agent: String, } -impl Default for PoolConfig { +impl Default for StratumV1PoolConfig { fn default() -> Self { Self { url: String::new(), username: String::new(), - password: String::new(), + password: None, user_agent: "mujina-miner/0.1.0-alpha".to_string(), } } } +impl std::fmt::Debug for StratumV1PoolConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StratumV1PoolConfig") + .field("url", &self.url) + .field("username", &self.username) + .field("password", &self.password.as_ref().map(|_| "[redacted]")) + .field("user_agent", &self.user_agent) + .finish() + } +} + /// Stratum v1 client. /// /// Manages connection to a mining pool, handles the protocol lifecycle @@ -49,7 +70,7 @@ impl Default for PoolConfig { /// we process notifications inline while waiting for responses. pub struct StratumV1Client { /// Pool configuration - config: PoolConfig, + config: StratumV1PoolConfig, /// Where to send events event_tx: mpsc::Sender, @@ -91,7 +112,7 @@ struct ProtocolState { impl StratumV1Client { /// Create a new Stratum v1 client. pub fn new( - config: PoolConfig, + config: StratumV1PoolConfig, event_tx: mpsc::Sender, shutdown: CancellationToken, ) -> Self { @@ -108,7 +129,7 @@ impl StratumV1Client { /// Create a new Stratum v1 client with command channel. pub fn with_commands( - config: PoolConfig, + config: StratumV1PoolConfig, event_tx: mpsc::Sender, command_rx: mpsc::Receiver, shutdown: CancellationToken, @@ -364,7 +385,10 @@ impl StratumV1Client { .send_request( conn, "mining.authorize", - json!([&self.config.username, &self.config.password]), + json!([ + &self.config.username, + self.config.password.as_deref().unwrap_or("x") + ]), Duration::from_secs(30), ) .await?; @@ -943,10 +967,10 @@ mod tests { let (event_tx, mut event_rx) = mpsc::channel(100); let shutdown = CancellationToken::new(); - let config = PoolConfig { + let config = StratumV1PoolConfig { url: format!("stratum+tcp://{}", pool_url), username: username.to_string(), - password: "x".to_string(), + password: Some("x".to_string()), user_agent: "mujina-miner/0.1.0-test".to_string(), }; @@ -1105,10 +1129,10 @@ mod tests { let (event_tx, event_rx) = mpsc::channel(10); let shutdown = CancellationToken::new(); - let config = PoolConfig { + let config = StratumV1PoolConfig { url: "test:3333".to_string(), username: "test".to_string(), - password: "x".to_string(), + password: Some("x".to_string()), user_agent: "test".to_string(), }; diff --git a/mujina-miner/src/stratum_v1/mod.rs b/mujina-miner/src/stratum_v1/mod.rs index c6de9f8c..64d202ee 100644 --- a/mujina-miner/src/stratum_v1/mod.rs +++ b/mujina-miner/src/stratum_v1/mod.rs @@ -21,10 +21,10 @@ //! # Usage //! //! ```rust,ignore -//! use stratum_v1::{StratumV1Client, ClientEvent, PoolConfig}; +//! use stratum_v1::{StratumV1Client, ClientEvent, StratumV1PoolConfig}; //! //! let (event_tx, mut event_rx) = mpsc::channel(100); -//! let config = PoolConfig { +//! let config = StratumV1PoolConfig { //! url: "stratum+tcp://pool.example.com:3333".to_string(), //! username: "worker".to_string(), //! password: "x".to_string(), @@ -47,7 +47,7 @@ mod connection; mod error; mod messages; -pub use client::{PoolConfig, StratumV1Client}; +pub use client::{StratumV1Client, StratumV1PoolConfig}; pub use connection::{Connector, TcpConnector, Transport}; #[cfg(test)] pub(crate) use connection::{MockConnector, MockTransport, MockTransportHandle};