From 06afbee4f891b547d40e6f10b0b7a4a0db2f0e96 Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 18 Aug 2026 14:53:46 -0500 Subject: [PATCH] =?UTF-8?q?fix(mining):=20remediate=20mining-tab=20audit?= =?UTF-8?q?=20(22=20findings)=20=E2=80=94=20crash-safety,=20async=20contro?= =?UTF-8?q?l,=20validation,=20math?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes all 22 confirmed findings from the mining-tab audit (10 Medium, 12 Low; 0 Critical/High), adversarially reviewed (6 follow-ups found + fixed, incl. the review-caught idle-auto-start bypass and a wrong benchmark-restore condition). Crash-safety & lifecycle: - M-04: join a stale/finished monitor thread in XmrigManager::start() and ~XmrigManager so an xmrig crash-then-restart (or quit) no longer std::terminate()s the wallet. - L-03/L-10: surface an unexpected miner exit once and clear the stale running flag. UI never blocks (M-03/L-06/L-08/L-09/L-13): pool start/stop now run on a dedicated serialized FIFO mining-control thread (joined before teardown), so the ~13 call sites don't block the render thread on stop()'s SIGTERM->SIGKILL->join; the spawn result marshals back to the UI. Miner-process / pool trust boundary: - M-01: validate the payout address (util::isValidRecipientAddress) at EVERY start path — the UI gate AND App::startPoolMining() (idle auto-start / thread scaling) — so a stale/wrong-chain address can't silently lose rewards. - M-09: SSRF guard skips the background pool-stats GET for loopback/private/link-local/single-label hosts. - M-02/L-02: cap the pool-stats + xmrig-API HTTP response bodies. - L-01: write the xmrig config 0600 at creation (POSIX open with mode) — no world/group-readable window. - M-10: reject shell-metacharacter binary paths before the version popen (excluding '()' so Program Files (x86) still works). Solo mining: M-06/M-08 clamp thread count to [1, cores] at the setgenerate/xmrig boundary; M-07 notify + don't lie on stop failure. Correctness: L-05 block-time constant 75->150s (chainparams); M-05 discloses pool-mode "Est. Daily" as a rough solo-equivalent; L-04/L-11/L-12 benchmark lifecycle (cancel on nav-away / mode-switch with restore, skip rebalance mid-benchmark); L-07 honor cancel mid-extract in both the xmrig and daemon updaters. Two new i18n keys back-filled across all 8 languages; CJK subset font rebuilt for the new glyphs. Verified across full-node, lite, and Windows builds; tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- res/fonts/NotoSansCJK-Subset.ttf | Bin 669212 -> 670108 bytes res/lang/de.json | 2 + res/lang/es.json | 2 + res/lang/fr.json | 2 + res/lang/ja.json | 2 + res/lang/ko.json | 2 + res/lang/pt.json | 2 + res/lang/ru.json | 2 + res/lang/zh.json | 2 + src/app.cpp | 66 +++++++++++++++++++ src/app.h | 11 ++++ src/app_network.cpp | 90 +++++++++++++++++--------- src/app_security.cpp | 4 ++ src/daemon/xmrig_manager.cpp | 87 +++++++++++++++++++++---- src/ui/windows/mining_controls.cpp | 7 +- src/ui/windows/mining_earnings.cpp | 10 ++- src/ui/windows/mining_mode_toggle.cpp | 5 ++ src/ui/windows/mining_tab.cpp | 13 ++++ src/ui/windows/mining_tab.h | 7 ++ src/ui/windows/mining_tab_helpers.cpp | 2 +- src/util/daemon_updater.cpp | 1 + src/util/i18n.cpp | 2 + src/util/pool_stats_service.cpp | 10 ++- src/util/xmrig_updater.cpp | 1 + 24 files changed, 285 insertions(+), 47 deletions(-) diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index 9d09bd06c7205c6e54a3061cbf2b36dda2b41f21..5d58ab03d7cd0603adec2441a3b2912a41fc222c 100644 GIT binary patch delta 17171 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 G^Zx;0xQV3z diff --git a/res/lang/de.json b/res/lang/de.json index dee943f..450dbc1 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -886,6 +886,7 @@ "mining_difficulty_copied": "Schwierigkeit kopiert", "mining_est_block": "Gesch. Block", "mining_est_daily": "Gesch. täglich", + "mining_est_daily_pool_sub": "grobe Solo-Äquivalenz, vor Pool-Gebühr", "mining_filter_all": "Alle", "mining_filter_tip_all": "Alle Einnahmen anzeigen", "mining_filter_tip_pool": "Nur Pool-Einnahmen anzeigen", @@ -914,6 +915,7 @@ "mining_open_in_explorer": "Im Explorer öffnen", "mining_payout_address": "Auszahlungsadresse", "mining_payout_foreign": "⚠ Diese Auszahlungsadresse befindet sich nicht in Ihrer aktuellen Wallet — geschürfte Belohnungen würden an eine andere Wallet gehen. Aktualisieren Sie sie, wenn Sie die Wallet gewechselt haben.", + "mining_payout_invalid": "Keine gültige DragonX-Adresse — vor dem Start korrigieren, sonst gehen die Mining-Belohnungen verloren.", "mining_payout_tooltip": "Adresse für Mining-Belohnungen", "mining_pool": "Pool", "mining_pool_fee": "Gebühr", diff --git a/res/lang/es.json b/res/lang/es.json index 78009cd..4bf7d17 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -886,6 +886,7 @@ "mining_difficulty_copied": "Dificultad copiada", "mining_est_block": "Bloque Est.", "mining_est_daily": "Diario Est.", + "mining_est_daily_pool_sub": "equivalente solo aproximado, antes de la comisión del pool", "mining_filter_all": "Todos", "mining_filter_tip_all": "Mostrar todas las ganancias", "mining_filter_tip_pool": "Mostrar solo ganancias del pool", @@ -914,6 +915,7 @@ "mining_open_in_explorer": "Abrir en explorador", "mining_payout_address": "Dirección de Pago", "mining_payout_foreign": "⚠ Esta dirección de pago no está en tu cartera actual — las recompensas minadas irían a otra cartera. Actualízala si cambiaste de cartera.", + "mining_payout_invalid": "No es una dirección DragonX válida — corrígela antes de empezar, o se pierden las recompensas de minería.", "mining_payout_tooltip": "Dirección para recibir recompensas de minería", "mining_pool": "Pool", "mining_pool_fee": "Comisión", diff --git a/res/lang/fr.json b/res/lang/fr.json index b5ae70a..2030811 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -886,6 +886,7 @@ "mining_difficulty_copied": "Difficulté copiée", "mining_est_block": "Bloc est.", "mining_est_daily": "Est. quotidien", + "mining_est_daily_pool_sub": "équivalent solo approximatif, avant les frais du pool", "mining_filter_all": "Tout", "mining_filter_tip_all": "Afficher tous les gains", "mining_filter_tip_pool": "Afficher uniquement les gains du pool", @@ -914,6 +915,7 @@ "mining_open_in_explorer": "Ouvrir dans l'explorateur", "mining_payout_address": "Adresse de paiement", "mining_payout_foreign": "⚠ Cette adresse de paiement ne fait pas partie de votre portefeuille actuel — les récompenses minées iraient vers un autre portefeuille. Mettez-la à jour si vous avez changé de portefeuille.", + "mining_payout_invalid": "Adresse DragonX invalide — corrigez-la avant de démarrer, sinon les récompenses de minage sont perdues.", "mining_payout_tooltip": "Adresse pour recevoir les récompenses de minage", "mining_pool": "Pool", "mining_pool_fee": "Frais", diff --git a/res/lang/ja.json b/res/lang/ja.json index 931fb5d..72e5117 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -886,6 +886,7 @@ "mining_difficulty_copied": "難易度をコピーしました", "mining_est_block": "予測ブロック", "mining_est_daily": "予測日収", + "mining_est_daily_pool_sub": "おおよそのソロ換算(プール手数料前)", "mining_filter_all": "すべて", "mining_filter_tip_all": "すべての収益を表示", "mining_filter_tip_pool": "プール収益のみ表示", @@ -914,6 +915,7 @@ "mining_open_in_explorer": "エクスプローラーで開く", "mining_payout_address": "支払いアドレス", "mining_payout_foreign": "⚠ この支払いアドレスは現在のウォレットに含まれていません — マイニング報酬が別のウォレットに送られます。ウォレットを切り替えた場合は更新してください。", + "mining_payout_invalid": "有効な DragonX アドレスではありません — 開始前に修正してください。さもないとマイニング報酬が失われます。", "mining_payout_tooltip": "マイニング報酬の受取アドレス", "mining_pool": "プール", "mining_pool_fee": "手数料", diff --git a/res/lang/ko.json b/res/lang/ko.json index 0a3d210..05aa546 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -885,6 +885,7 @@ "mining_difficulty_copied": "난이도가 복사되었습니다", "mining_est_block": "예상 블록", "mining_est_daily": "예상 일일 수익", + "mining_est_daily_pool_sub": "대략적인 솔로 환산, 풀 수수료 전", "mining_filter_all": "전체", "mining_filter_tip_all": "모든 수익 표시", "mining_filter_tip_pool": "풀 수익만 표시", @@ -913,6 +914,7 @@ "mining_open_in_explorer": "탐색기에서 열기", "mining_payout_address": "지급 주소", "mining_payout_foreign": "⚠ 이 지급 주소는 현재 지갑에 없습니다 — 채굴한 보상이 다른 지갑으로 전송됩니다. 지갑을 전환했다면 주소를 업데이트하세요.", + "mining_payout_invalid": "유효한 DragonX 주소가 아닙니다 — 시작하기 전에 수정하세요. 그렇지 않으면 채굴 보상이 사라집니다.", "mining_payout_tooltip": "채굴 보상 수신 주소", "mining_pool": "풀", "mining_pool_fee": "수수료", diff --git a/res/lang/pt.json b/res/lang/pt.json index 6d0c0bf..07bceae 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -886,6 +886,7 @@ "mining_difficulty_copied": "Dificuldade copiada", "mining_est_block": "Bloco Est.", "mining_est_daily": "Est. Diário", + "mining_est_daily_pool_sub": "equivalente solo aproximado, antes da taxa do pool", "mining_filter_all": "Todos", "mining_filter_tip_all": "Mostrar todos os ganhos", "mining_filter_tip_pool": "Mostrar apenas ganhos do pool", @@ -914,6 +915,7 @@ "mining_open_in_explorer": "Abrir no explorador", "mining_payout_address": "Endereço de Pagamento", "mining_payout_foreign": "⚠ Este endereço de pagamento não está na sua carteira atual — as recompensas mineradas iriam para uma carteira diferente. Atualize-o se você trocou de carteira.", + "mining_payout_invalid": "Endereço DragonX inválido — corrija antes de iniciar, ou as recompensas de mineração serão perdidas.", "mining_payout_tooltip": "Endereço para receber recompensas de mineração", "mining_pool": "Pool", "mining_pool_fee": "Taxa", diff --git a/res/lang/ru.json b/res/lang/ru.json index e476059..b6842d2 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -886,6 +886,7 @@ "mining_difficulty_copied": "Сложность скопирована", "mining_est_block": "Расч. блок", "mining_est_daily": "Расч. за день", + "mining_est_daily_pool_sub": "примерный соло-эквивалент, до комиссии пула", "mining_filter_all": "Все", "mining_filter_tip_all": "Показать все доходы", "mining_filter_tip_pool": "Показать только доходы пула", @@ -914,6 +915,7 @@ "mining_open_in_explorer": "Открыть в обозревателе", "mining_payout_address": "Адрес выплат", "mining_payout_foreign": "⚠ Этот адрес выплат отсутствует в вашем текущем кошельке — намайненные вознаграждения будут отправлены в другой кошелёк. Обновите его, если вы сменили кошелёк.", + "mining_payout_invalid": "Недействительный адрес DragonX — исправьте перед запуском, иначе награды за майнинг будут потеряны.", "mining_payout_tooltip": "Адрес для получения вознаграждений за майнинг", "mining_pool": "Пул", "mining_pool_fee": "Комиссия", diff --git a/res/lang/zh.json b/res/lang/zh.json index fa7c265..7c51509 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -885,6 +885,7 @@ "mining_difficulty_copied": "难度已复制", "mining_est_block": "预计区块", "mining_est_daily": "预计日收益", + "mining_est_daily_pool_sub": "粗略的单人挖矿等值,扣除矿池费用前", "mining_filter_all": "全部", "mining_filter_tip_all": "显示所有收益", "mining_filter_tip_pool": "仅显示矿池收益", @@ -913,6 +914,7 @@ "mining_open_in_explorer": "在浏览器中打开", "mining_payout_address": "支付地址", "mining_payout_foreign": "⚠ 此支付地址不在您当前的钱包中——挖矿奖励将进入另一个钱包。如果您切换过钱包,请更新它。", + "mining_payout_invalid": "不是有效的 DragonX 地址——启动前请更正,否则挖矿奖励将丢失。", "mining_payout_tooltip": "接收挖矿奖励的地址", "mining_pool": "矿池", "mining_pool_fee": "费用", diff --git a/src/app.cpp b/src/app.cpp index 2991453..7a94b2a 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -147,6 +147,45 @@ void App::wipeSecrets() sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET) } +// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread +// never blocks on stop()'s SIGTERM->SIGKILL->join, while start/stop still execute in FIFO order. (M-03/…) +void App::postMiningControl(std::function job) +{ + { + std::lock_guard lk(mining_ctl_mutex_); + if (mining_ctl_stop_) return; // shutting down — don't enqueue new mining ops + if (!mining_ctl_thread_.joinable()) { + mining_ctl_thread_ = std::thread([this]() { + for (;;) { + std::function j; + { + std::unique_lock lk(mining_ctl_mutex_); + mining_ctl_cv_.wait(lk, [this]{ return mining_ctl_stop_ || !mining_ctl_queue_.empty(); }); + if (mining_ctl_stop_) return; // abandon any pending jobs on shutdown + j = std::move(mining_ctl_queue_.front()); + mining_ctl_queue_.pop_front(); + } + j(); + } + }); + } + mining_ctl_queue_.push_back(std::move(job)); + } + mining_ctl_cv_.notify_one(); +} + +// Signal the mining-control thread to stop and join it. Called at shutdown BEFORE xmrig_manager_ is stopped +// or destroyed, so no control job runs concurrently with teardown. Idempotent. +void App::stopMiningControlThread() +{ + { + std::lock_guard lk(mining_ctl_mutex_); + mining_ctl_stop_ = true; + } + mining_ctl_cv_.notify_all(); + if (mining_ctl_thread_.joinable()) mining_ctl_thread_.join(); +} + namespace { // How often auto-balance re-evaluates the pool while active. Long, because switching // restarts the miner (drops in-flight shares + reconnect); the incumbent stickiness @@ -166,6 +205,7 @@ void App::updatePoolAutoBalance() if (!supportsPoolMining()) return; // pool mining is available in both builds (solo is full-node only) if (settings_->getPoolSelectMode() != config::Settings::PoolSelectMode::AutoBalance) return; if (!settings_->getPoolMode()) return; // only while POOL mode is selected + if (ui::IsMiningBenchmarkActive()) return; // don't auto-switch pools mid-benchmark — it restarts xmrig at the wrong thread count (L-12) const long long now = steadyNowMs(); const bool intervalDue = (last_balance_eval_ms_ == 0) || @@ -965,6 +1005,20 @@ void App::update() } } + // Surface an unexpected miner exit (crash / OOM-kill / external SIGKILL) once, and clear the stale + // running flag so the UI and auto-balance don't keep believing it's still hashing. (L-03, L-10) + if (xmrig_manager_ && state_.pool_mining.xmrig_running + && xmrig_manager_->getState() == daemon::XmrigManager::State::Error) { + state_.pool_mining.xmrig_running = false; + state_.pool_mining.hashrate_10s = 0.0; + state_.pool_mining.hashrate_60s = 0.0; + state_.pool_mining.hashrate_15m = 0.0; + pool_starting_.store(false, std::memory_order_relaxed); + const std::string err = xmrig_manager_->getLastError(); + ui::Notifications::instance().error(err.empty() ? "Miner stopped unexpectedly." + : ("Miner stopped: " + err)); + } + // Poll xmrig stats every ~2 seconds (use a simple toggle) static bool xmrig_poll_tick = false; xmrig_poll_tick = !xmrig_poll_tick; @@ -1797,6 +1851,10 @@ void App::render() if ((current_page_ == ui::NavPage::Console || current_page_ == ui::NavPage::LiteConsole) && settings_ && settings_->getConsoleAutoFocus()) console_tab_.requestInputFocus(); + // Leaving the Mining tab → cancel a running thread benchmark so the miner isn't abandoned at a + // benchmark step. (L-04) + if (prev_page_ == ui::NavPage::Mining && current_page_ != ui::NavPage::Mining) + ui::CancelMiningBenchmark(this); prev_page_ = current_page_; } if (page_alpha_ < 1.0f) { @@ -5595,6 +5653,10 @@ void App::beginShutdown() fast_worker_->requestStop(); } + // Drain + join the mining-control thread FIRST so no async start/stop job runs while we tear the miner + // down here (avoids two threads driving xmrig_manager_ during shutdown). (M-03 cluster) + stopMiningControlThread(); + // Stop xmrig pool miner before stopping the daemon if (xmrig_manager_ && xmrig_manager_->isRunning()) { shutdown_status_ = "Stopping pool miner..."; @@ -6380,6 +6442,10 @@ void App::renderLoadingOverlay(float contentH) void App::shutdown() { + // Ensure the mining-control thread is stopped + joined (idempotent; beginShutdown already did it on the + // normal quit path, but shutdown() can also run without it). Must precede xmrig_manager_ teardown. + stopMiningControlThread(); + // Wipe any copied secret from the OS clipboard before we exit — the 45s auto-clear timer // never fires if the user quits sooner, which would otherwise leave a key/seed resident. // (ImGui context is still alive here; App::shutdown() runs before ImGui::DestroyContext().) diff --git a/src/app.h b/src/app.h index 48c9cac..323f1d1 100644 --- a/src/app.h +++ b/src/app.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include "data/transaction_history_cache.h" #include "data/address_book.h" @@ -855,6 +856,16 @@ private: bool lite_startup_lock_checked_ = false; std::unique_ptr daemon_controller_; std::unique_ptr xmrig_manager_; + // Serialized async mining-control queue: xmrig start/stop (SIGTERM->SIGKILL->join, up to ~3s) run on + // this dedicated FIFO thread instead of the render thread, so the UI never blocks and stop/start + // ordering is preserved across the ~13 call sites. (M-03/L-06/L-08/L-09/L-13) + std::thread mining_ctl_thread_; + std::mutex mining_ctl_mutex_; + std::condition_variable mining_ctl_cv_; + std::deque> mining_ctl_queue_; + bool mining_ctl_stop_ = false; + void postMiningControl(std::function job); // enqueue a blocking xmrig op onto the FIFO thread + void stopMiningControlThread(); // signal + join the control thread (shutdown) // Auto-balance runtime state (pool mining, full-node only). The service fetches // pool hashrates off-thread; the RNG drives the weighted-random pick. util::PoolStatsService pool_stats_service_; diff --git a/src/app_network.cpp b/src/app_network.cpp index e586e9e..64b0eca 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -63,6 +63,7 @@ #include // popen the rebuild helper #include "util/perf_log.h" #include "util/i18n.h" +#include "util/address_validation.h" // isValidRecipientAddress — payout validation at every start path (M-01) #include "util/secure_vault.h" #include @@ -2338,6 +2339,10 @@ void App::startMining(int threads) return; } if (!state_.connected || !rpc_ || !worker_) return; + // Clamp the requested thread count to [1, logical cores] before setgenerate — an unclamped value + // (from a settings field or idle-scaling) would ask the daemon to spawn arbitrarily many threads. (M-08) + const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency()); + threads = std::clamp(threads, 1, maxThreads); if (mining_toggle_in_progress_.exchange(true)) return; // already in progress worker_->post([this, threads]() -> rpc::RPCWorker::MainCb { @@ -2372,19 +2377,25 @@ void App::stopMining() worker_->post([this]() -> rpc::RPCWorker::MainCb { bool ok = false; + std::string errMsg; try { rpc::RPCClient::TraceScope trace("Mining tab / Stop mining"); rpc_->call("setgenerate", {false, 0}); ok = true; } catch (const std::exception& e) { - DEBUG_LOGF("Failed to stop mining: %s\n", e.what()); + errMsg = e.what(); + DEBUG_LOGF("Failed to stop mining: %s\n", errMsg.c_str()); } - return [this, ok]() { + return [this, ok, errMsg]() { mining_toggle_in_progress_.store(false); if (ok) { state_.mining.generate = false; state_.mining.localHashrate = 0.0; DEBUG_LOGF("Mining stopped\n"); + } else { + // Don't silently leave generate=true as if it worked: tell the user and let the next + // getmininginfo refresh reconcile the true daemon state. (M-07) + ui::Notifications::instance().error("Failed to stop mining: " + errMsg); } }; }); @@ -2397,21 +2408,28 @@ void App::startPoolMining(int threads) ui::Notifications::instance().warning("Pool mining is unavailable in this build"); return; } + // Clamp to [1, logical cores] before the count reaches xmrig (M-06/M-08 pool path). + threads = std::clamp(threads, 1, std::max(1, (int)std::thread::hardware_concurrency())); if (!xmrig_manager_) xmrig_manager_ = std::make_unique(); - // If already running, stop first (e.g. thread count change) - if (xmrig_manager_->isRunning()) { - xmrig_manager_->stop(); - } - - // Stop solo mining first if active + // Stop solo mining first if active (async via the RPC worker). if (state_.mining.generate) stopMining(); + // (the "stop the already-running miner first" step is done inside the control job below, in FIFO order) daemon::XmrigManager::Config cfg; cfg.pool_url = settings_->getPoolUrl(); cfg.worker_name = settings_->getPoolWorker(); + // Validate the payout address at EVERY start entry point (manual Start button, idle auto-start, thread + // scaling) — not just the UI gate — since a stale/hand-edited/wrong-chain address here silently loses + // mining rewards. (M-01) worker_name IS the pool login the rewards are credited to (see below). + if (!cfg.worker_name.empty() && cfg.worker_name != "x" && + !util::isValidRecipientAddress(cfg.worker_name)) { + ui::Notifications::instance().error( + "Pool payout address is not a valid DragonX address — mining not started."); + return; + } // The algo follows the pool: official pools use their own algo (pool.dragonx.cc // needs rx/dragonx, pool.dragonx.is rx/hush); custom hosts keep the setting. cfg.algo = util::resolvePoolAlgo(cfg.pool_url, settings_->getPoolAlgo()); @@ -2443,34 +2461,46 @@ void App::startPoolMining(int threads) return; } - if (!xmrig_manager_->start(cfg)) { - std::string err = xmrig_manager_->getLastError(); - DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str()); - - // Check for Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED) - if (err.find("error 225") != std::string::npos || - err.find("virus") != std::string::npos) { - ui::Notifications::instance().error( - "Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon"); + // Run the blocking stop(if running)+start on the serialized mining-control thread so the render thread + // never blocks on stop()'s SIGTERM->SIGKILL->join; marshal the spawn result back to the UI. (M-03/L-06/ + // L-08/L-09/L-13). cfg was fully built above on this (main) thread. + daemon::XmrigManager::Config cfgCopy = cfg; + postMiningControl([this, cfgCopy]() { + if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000); + const bool ok = xmrig_manager_->start(cfgCopy); + const std::string err = ok ? std::string() : xmrig_manager_->getLastError(); + if (!worker_) return; + worker_->post([this, ok, err]() -> rpc::RPCWorker::MainCb { + return [this, ok, err]() { + if (ok) { + // Miner spawned — it still needs a few seconds to connect to the pool and start hashing. + pool_starting_.store(true, std::memory_order_relaxed); + ui::Notifications::instance().info("Starting pool miner — connecting to the pool…"); + } else { + DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str()); + // Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED) + if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) { + ui::Notifications::instance().error( + "Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon"); #ifdef _WIN32 - // Offer to open Windows Security settings - pending_antivirus_dialog_ = true; + pending_antivirus_dialog_ = true; #endif - } else { - ui::Notifications::instance().error("Failed to start pool miner: " + err); - } - } else { - // Miner spawned — it still needs a few seconds to connect to the pool and start hashing. - pool_starting_.store(true, std::memory_order_relaxed); - ui::Notifications::instance().info("Starting pool miner — connecting to the pool…"); - } + } else { + ui::Notifications::instance().error("Failed to start pool miner: " + err); + } + } + }; + }); + }); } void App::stopPoolMining() { - if (xmrig_manager_ && xmrig_manager_->isRunning()) { - xmrig_manager_->stop(3000); - } + if (!xmrig_manager_) return; + // Off the render thread — stop()'s SIGTERM->SIGKILL->join can block up to ~3s. (M-03/L-06/L-08/L-09) + postMiningControl([this]() { + if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000); + }); } // ============================================================================ diff --git a/src/app_security.cpp b/src/app_security.cpp index 192f986..845894b 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -759,6 +759,10 @@ void App::checkIdleMining() { // Resolve auto values: active defaults to half, idle defaults to all if (activeThreads <= 0) activeThreads = std::max(1, maxThreads / 2); if (idleThreads <= 0) idleThreads = maxThreads; + // Clamp to [1, logical cores] before these reach setgenerate / startPoolMining — a settings field + // could otherwise carry an arbitrary count straight past every bound. (M-06) + activeThreads = std::clamp(activeThreads, 1, maxThreads); + idleThreads = std::clamp(idleThreads, 1, maxThreads); if (systemIdle) { // System is idle — scale up to idle thread count diff --git a/src/daemon/xmrig_manager.cpp b/src/daemon/xmrig_manager.cpp index 9ca5a76..4d76c21 100644 --- a/src/daemon/xmrig_manager.cpp +++ b/src/daemon/xmrig_manager.cpp @@ -89,8 +89,32 @@ static std::string getConfigDir() { // libcurl write callback static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) { auto* s = static_cast(userdata); - s->append(static_cast(ptr), sz * n); - return sz * n; + const size_t add = sz * n; + // Stats JSON (local xmrig HTTP API + pool API) is tiny; refuse an unbounded body from a hostile or + // MITM'd endpoint so it can't grow this string until OOM. Returning < add aborts the transfer. (L-02) + constexpr size_t kMaxStatsBytes = 1u << 20; // 1 MiB + if (s->size() + add > kMaxStatsBytes) return 0; + s->append(static_cast(ptr), add); + return add; +} + +// True if `host` (already stripped of scheme+port) is a loopback/private/link-local/single-label target +// that a public mining pool would never be — used to refuse a background stats GET to it (M-09). +static bool hostLooksInternal(const std::string& host) { + if (host.empty() || host == "localhost") return true; + if (host.rfind("127.", 0) == 0 || host.rfind("10.", 0) == 0 || + host.rfind("192.168.", 0) == 0 || host.rfind("169.254.", 0) == 0) return true; + if (host.rfind("172.", 0) == 0) { // 172.16.0.0 - 172.31.255.255 + const int second = std::atoi(host.c_str() + 4); + if (second >= 16 && second <= 31) return true; + } + if (host.find(':') != std::string::npos) { // IPv6 literal: loopback / ULA / link-local + if (host == "::1" || host.rfind("fc", 0) == 0 || host.rfind("fd", 0) == 0 || + host.rfind("fe80", 0) == 0) return true; + } + if (host.size() >= 6 && host.compare(host.size() - 6, 6, ".local") == 0) return true; + if (host.find('.') == std::string::npos) return true; // bare single-label name = LAN/hosts, not a pool + return false; } // ============================================================================ @@ -100,9 +124,14 @@ static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) { XmrigManager::XmrigManager() = default; XmrigManager::~XmrigManager() { + should_stop_ = true; if (isRunning()) { stop(3000); } + // Join a monitor thread left joinable by an unexpected xmrig exit (State::Error, so isRunning() is + // false and stop() above was skipped) — std::thread's destructor would otherwise std::terminate(). (M-04) + if (monitor_thread_.joinable()) + monitor_thread_.join(); } // ============================================================================ @@ -208,21 +237,41 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath) try { fs::create_directories(fs::path(outPath).parent_path()); + const std::string dumped = j.dump(4); +#ifndef _WIN32 + // Create the config 0600 AT CREATION (open with mode) so the API token + wallet address are never + // in a world/group-readable file — even for a local attacker who opened it in the old + // create-then-chmod window and held the fd open across the chmod. (L-01) + int fd = ::open(outPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) { + setLastError("Cannot write xmrig config: " + outPath); + DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); + return false; + } + size_t off = 0; + bool wrote = true; + while (off < dumped.size()) { + ssize_t nw = ::write(fd, dumped.data() + off, dumped.size() - off); + if (nw <= 0) { wrote = false; break; } + off += static_cast(nw); + } + ::close(fd); + if (!wrote) { + setLastError("Cannot write xmrig config: " + outPath); + return false; + } + return true; +#else std::ofstream ofs(outPath, std::ios::trunc); if (!ofs.is_open()) { setLastError("Cannot write xmrig config: " + outPath); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); return false; } -#ifndef _WIN32 - // Restrict to owner (0600) BEFORE writing any secret material (API token, wallet - // address, worker name). The file is still empty here, so the config is never - // world-readable — closing the window between creation and the previous post-write chmod. - chmod(outPath.c_str(), 0600); -#endif - ofs << j.dump(4); + ofs << dumped; ofs.close(); return true; +#endif } catch (const std::exception& e) { setLastError(std::string("Config write error: ") + e.what()); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); @@ -296,7 +345,11 @@ bool XmrigManager::start(const Config& cfg) { return false; } - // Start monitor thread + // Join a prior monitor thread before move-assigning: if xmrig exited unexpectedly, monitorProcess set + // State::Error and returned, leaving monitor_thread_ joinable — move-assigning over a joinable + // std::thread calls std::terminate() and aborts the whole wallet. (M-04) + if (monitor_thread_.joinable()) + monitor_thread_.join(); monitor_thread_ = std::thread(&XmrigManager::monitorProcess, this); state_ = State::Running; DEBUG_LOGF("[INFO] XmrigManager: started\n"); @@ -783,6 +836,11 @@ void XmrigManager::fetchPoolApiStats() { // own API shape (pool.dragonx.is = custom /api/stats; pool.dragonx.cc = Miningcore // /api/pools); unknown/custom hosts fall back to the .is convention. const util::KnownPool* known = util::findKnownPoolByUrl(pool_host_); + // SSRF guard: for an UNKNOWN (user-typed) pool host, don't let the wallet issue a background GET to a + // loopback/private/link-local/single-label target — those aren't public mining pools, and a + // paste-a-pool-config lure could otherwise point us at an internal host. Known pools use their trusted + // registry statsUrl and are exempt. (M-09) + if (!known && hostLooksInternal(pool_host_)) return; const std::string url = known ? known->statsUrl : ("https://" + pool_host_ + "/api/stats"); @@ -861,7 +919,14 @@ void XmrigManager::startVersionDetection() std::thread([]() { const std::string bin = findXmrigBinary(); std::string ver; - if (!bin.empty()) { + // Don't hand a path containing shell/cmd metacharacters to popen()'s shell — bin is normally an + // app-controlled path, but this closes command injection if it ever isn't. (M-10) + // Reject only chars that stay shell-special INSIDE the double-quotes we wrap bin in ("\"" + bin + "\"") + // on cmd.exe or /bin/sh. Parens are inert when quoted, so they're excluded — otherwise common Windows + // paths like "C:\Program Files (x86)\..." would be rejected and version detection would silently fail. (M-10) + const bool binShellSafe = + !bin.empty() && bin.find_first_of("\"'`$;&|<>^%\n\r") == std::string::npos; + if (binShellSafe) { const std::string cmd = "\"" + bin + "\" --version 2>&1"; #ifdef _WIN32 FILE* fp = _popen(cmd.c_str(), "r"); diff --git a/src/ui/windows/mining_controls.cpp b/src/ui/windows/mining_controls.cpp index 133db30..8e25f47 100644 --- a/src/ui/windows/mining_controls.cpp +++ b/src/ui/windows/mining_controls.cpp @@ -12,6 +12,7 @@ #include "../../config/settings.h" #include "../../util/i18n.h" #include "../../util/platform.h" +#include "../../util/address_validation.h" // isValidRecipientAddress — payout validation (M-01) #include "../schema/ui_schema.h" #include "../material/type.h" #include "../material/draw_helpers.h" @@ -685,8 +686,12 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo& bool poolStillRunning = !s_pool_mode && state.pool_mining.xmrig_running; // Can't start pool mining without a payout address (blank for a new wallet with no z-address); // only blocks starting — stopping a running miner stays enabled. + // Block start when the payout address is empty OR not a valid DragonX address — mining to a + // malformed / wrong-chain address silently loses the rewards. (M-01) + const std::string poolPayoutStr(s_pool_worker); bool poolNeedsPayout = s_pool_mode && !state.pool_mining.xmrig_running && - std::string(s_pool_worker).empty(); + (poolPayoutStr.empty() || + (poolPayoutStr != "x" && !util::isValidRecipientAddress(poolPayoutStr))); bool disabled = s_pool_mode ? (isToggling || poolBlockedBySolo || poolNeedsPayout) : (poolStillRunning ? false : (!app->isConnected() || isToggling || isSyncing)); diff --git a/src/ui/windows/mining_earnings.cpp b/src/ui/windows/mining_earnings.cpp index 8c10471..2bccb17 100644 --- a/src/ui/windows/mining_earnings.cpp +++ b/src/ui/windows/mining_earnings.cpp @@ -104,7 +104,10 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo& } } - // Use pool hashrate for EST. DAILY when in pool mode + // Est. Daily = expected reward for YOUR own hashrate share of the network. In pool mode this uses + // your local miner rate (pool_mining.hashrate_10s), NOT the pool's aggregate rate: pool payouts are + // share-proportional, so your expected daily is the same solo-equivalent value (a rough estimate, + // before the pool fee). Using the pool's total rate here would show the POOL's earnings, not yours. (M-05) double estHashrate = s_pool_mode ? state.pool_mining.hashrate_10s : mining.localHashrate; double est_hours_2 = EstimateHoursToBlock(estHashrate, mining.networkHashrate, mining.difficulty); double estDailyBlocks = (est_hours_2 > 0) ? (24.0 / est_hours_2) : 0.0; @@ -218,12 +221,15 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo& snprintf(estVal, sizeof(estVal), "~%.4f", estDaily); else snprintf(estVal, sizeof(estVal), "N/A"); + // Disclose in pool mode that Est. Daily is a rough solo-equivalent (before the pool fee), so the + // number isn't silently mismatched to its plain "Est. Daily" label. (M-05) + const char* estSub = (s_pool_mode && estActive) ? TR("mining_est_daily_pool_sub") : nullptr; EarningsEntry entries[] = { { TR("mining_today"), todayVal, todaySub, greenCol2 }, { TR("mining_yesterday"), yesterdayVal, yesterdaySub, OnSurface() }, { TR("mining_all_time"), allVal, allSub, OnSurface() }, - { TR("mining_est_daily"), estVal, nullptr, estActive ? greenCol2 : OnSurfaceDisabled() }, + { TR("mining_est_daily"), estVal, estSub, estActive ? greenCol2 : OnSurfaceDisabled() }, }; for (int ei = 0; ei < numCols; ei++) { diff --git a/src/ui/windows/mining_mode_toggle.cpp b/src/ui/windows/mining_mode_toggle.cpp index 862fd36..65f7e2c 100644 --- a/src/ui/windows/mining_mode_toggle.cpp +++ b/src/ui/windows/mining_mode_toggle.cpp @@ -4,6 +4,7 @@ #include "mining_mode_toggle.h" #include "mining_tab_helpers.h" +#include "mining_tab.h" // CancelMiningBenchmark (L-11) #include "mining_pool_panel.h" #include "../../app.h" @@ -16,6 +17,7 @@ #include "../material/type.h" #include "../material/draw_helpers.h" #include "../material/colors.h" +#include "../../util/address_validation.h" // isValidRecipientAddress — payout validation (M-01) #include "../layout.h" #include "../notifications.h" #include "../../embedded/IconsMaterialDesign.h" @@ -101,6 +103,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo s_pool_mode = false; app->settings()->setPoolMode(false); app->settings()->save(); + CancelMiningBenchmark(app); // don't leave a pool benchmark running after switching to solo (L-11) app->stopPoolMining(); } if (soloHov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); @@ -382,6 +385,8 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo std::string currentWorkerStr(s_pool_worker); if (currentWorkerStr.empty()) { material::Tooltip("%s", TR("mining_generate_z_address_hint")); + } else if (currentWorkerStr != "x" && !util::isValidRecipientAddress(currentWorkerStr)) { + material::Tooltip("%s", TR("mining_payout_invalid")); // block start below too (M-01) } else { material::Tooltip("%s", TR("mining_payout_tooltip")); } diff --git a/src/ui/windows/mining_tab.cpp b/src/ui/windows/mining_tab.cpp index 49b8774..3749c87 100644 --- a/src/ui/windows/mining_tab.cpp +++ b/src/ui/windows/mining_tab.cpp @@ -55,6 +55,19 @@ bool IsMiningBenchmarkActive() { return s_benchmark.active(); } +void CancelMiningBenchmark(App* app) { + if (!s_benchmark.active()) return; + const int restoreThreads = s_benchmark.prev_threads; + s_benchmark.reset(); + // Restore the miner to its pre-benchmark thread count. A benchmark runs in pool mode, so restore + // regardless of the instantaneous running state — the sweep may be mid inter-candidate stop, where an + // isPoolMinerRunning() check would be transiently false and silently drop the restart. (L-04, L-11) + if (app && restoreThreads > 0) { + app->stopPoolMining(); + app->startPoolMining(restoreThreads); + } +} + // Miner-update version check (one shot per session): fetches the latest DRG-XMRig release tag in // the background so the "Update" button can show it. Network call to the project Gitea, started // the first time the pool section is shown. diff --git a/src/ui/windows/mining_tab.h b/src/ui/windows/mining_tab.h index 2f55762..0515925 100644 --- a/src/ui/windows/mining_tab.h +++ b/src/ui/windows/mining_tab.h @@ -21,5 +21,12 @@ void RenderMiningTab(App* app); */ bool IsMiningBenchmarkActive(); +/** + * @brief Cancel a running thread benchmark and restore the miner to its pre-benchmark thread count. + * Safe to call when no benchmark is active (no-op). Used when leaving the Mining tab or switching to + * solo mode so the miner isn't left stuck at a benchmark step. (L-04, L-11) + */ +void CancelMiningBenchmark(App* app); + } // namespace ui } // namespace dragonx diff --git a/src/ui/windows/mining_tab_helpers.cpp b/src/ui/windows/mining_tab_helpers.cpp index d1d8d7b..801e1b7 100644 --- a/src/ui/windows/mining_tab_helpers.cpp +++ b/src/ui/windows/mining_tab_helpers.cpp @@ -60,7 +60,7 @@ double EstimateHoursToBlock(double localHashrate, double networkHashrate, double { (void)difficulty; if (localHashrate <= 0.0 || networkHashrate <= 0.0) return 0.0; - double blocksPerHour = 3600.0 / 75.0; + double blocksPerHour = 3600.0 / 150.0; // DragonX mainnet target spacing is 150s (chainparams) (L-05) double share = localHashrate / networkHashrate; if (share <= 0.0) return 0.0; return 1.0 / (blocksPerHour * share); diff --git a/src/util/daemon_updater.cpp b/src/util/daemon_updater.cpp index d818c55..4dd905e 100644 --- a/src/util/daemon_updater.cpp +++ b/src/util/daemon_updater.cpp @@ -354,6 +354,7 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe bool failed = false; const int numFiles = static_cast(mz_zip_reader_get_num_files(&zip)); for (int i = 0; i < numFiles && !failed; ++i) { + if (cancel_requested_) { failed = true; break; } // honor cancel mid-extraction so the join returns promptly (L-07) mz_zip_archive_file_stat st; if (!mz_zip_reader_file_stat(&zip, i, &st)) continue; if (mz_zip_reader_is_file_a_directory(&zip, i)) continue; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index f5ab0ca..d591196 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1880,6 +1880,8 @@ void I18n::loadBuiltinEnglish() strings_["mining_open_in_explorer"] = "Open in explorer"; strings_["mining_payout_address"] = "Payout Address"; strings_["mining_payout_tooltip"] = "Address to receive mining rewards"; + strings_["mining_payout_invalid"] = "Not a valid DragonX address — fix it before starting, or mining rewards are lost."; + strings_["mining_est_daily_pool_sub"] = "rough solo-equivalent, before pool fee"; strings_["mining_generate_z_address_hint"] = "Generate a Z address in the Receive tab to use as your payout address"; strings_["mining_pool"] = "Pool"; strings_["mining_payout_foreign"] = "⚠ This payout address isn't in your current wallet — mined rewards would go to a different wallet. Update it if you switched wallets."; diff --git a/src/util/pool_stats_service.cpp b/src/util/pool_stats_service.cpp index 7bc479d..0be6a34 100644 --- a/src/util/pool_stats_service.cpp +++ b/src/util/pool_stats_service.cpp @@ -13,8 +13,14 @@ namespace { size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp) { - static_cast(userp)->append(static_cast(contents), size * nmemb); - return size * nmemb; + auto* s = static_cast(userp); + const size_t add = size * nmemb; + // Pool stats JSON is tiny; refuse an unbounded body from a hostile/MITM'd endpoint (returning < add + // aborts the transfer) so it can't grow this string until OOM. (M-02) + constexpr size_t kMaxPoolStatsBytes = 1u << 20; // 1 MiB + if (s->size() + add > kMaxPoolStatsBytes) return 0; + s->append(static_cast(contents), add); + return add; } // Returning non-zero asks libcurl to abort the transfer — used so shutdown doesn't diff --git a/src/util/xmrig_updater.cpp b/src/util/xmrig_updater.cpp index 026a115..67453a6 100644 --- a/src/util/xmrig_updater.cpp +++ b/src/util/xmrig_updater.cpp @@ -345,6 +345,7 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele bool failed = false; const int numFiles = static_cast(mz_zip_reader_get_num_files(&zip)); for (int i = 0; i < numFiles && !failed; ++i) { + if (cancel_requested_) { failed = true; break; } // honor cancel mid-extraction so the dialog's join returns promptly (L-07) mz_zip_archive_file_stat st; if (!mz_zip_reader_file_stat(&zip, i, &st)) continue; if (mz_zip_reader_is_file_a_directory(&zip, i)) continue;