Add a full-node daemon updater (util/DaemonUpdater + daemon_download_dialog) reachable from Settings -> NODE & SECURITY: downloads/verifies (SHA-256 + enforced ed25519 signature) and atomically installs the latest dragonxd from the project Gitea, with a "Restart daemon now" step. Add a shared "Browse all releases..." picker (release_list_view) to both the miner and daemon updaters so users can pin older/pre-release builds. Pure no-I/O cores (daemon_updater_core / xmrig_updater_core) are unit-tested; sign-daemon-release.sh signs release archives offline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
479 lines
19 KiB
C++
479 lines
19 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
//
|
|
// DaemonUpdater background worker (libcurl download + miniz extract). The pure, no-I/O helpers it
|
|
// calls (release parsing, asset/platform matching, the checksum-table parser, version core) live in
|
|
// daemon_updater_core.cpp; the generic SHA-256 / ed25519 verification is reused from the miner
|
|
// updater (xmrig_updater_core.cpp) via util::sha256Hex / util::verifyXmrigSignature.
|
|
|
|
#include "daemon_updater.h"
|
|
#include "xmrig_updater.h" // util::sha256Hex, util::verifyXmrigSignature (shared crypto)
|
|
|
|
#include "logger.h"
|
|
|
|
#include <curl/curl.h>
|
|
#include <miniz.h>
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstdio>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace dragonx {
|
|
namespace util {
|
|
|
|
namespace {
|
|
|
|
// Bound the archive/member sizes before they can fill memory/disk — a defense even ahead of the
|
|
// checksum check (which only runs once the file is fully downloaded). The full-node archive bundles
|
|
// the daemon binaries plus Sapling params, so the caps are well above the miner updater's.
|
|
constexpr curl_off_t kMaxArchiveBytes = 256LL * 1024 * 1024; // 256 MiB
|
|
constexpr std::size_t kMaxMemberBytes = 128u * 1024 * 1024; // 128 MiB per extracted file
|
|
|
|
size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp)
|
|
{
|
|
static_cast<std::string*>(userp)->append(static_cast<char*>(contents), size * nmemb);
|
|
return size * nmemb;
|
|
}
|
|
|
|
size_t writeFileCb(void* contents, size_t size, size_t nmemb, void* userp)
|
|
{
|
|
return std::fwrite(contents, size, nmemb, static_cast<FILE*>(userp));
|
|
}
|
|
|
|
// libcurl progress callback: publish live byte counts and abort the transfer on cancel().
|
|
int xferCb(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t, curl_off_t)
|
|
{
|
|
auto* up = static_cast<DaemonUpdater*>(clientp);
|
|
return up->onDownloadProgress(static_cast<double>(dlnow), static_cast<double>(dltotal)) ? 0 : 1;
|
|
}
|
|
|
|
std::string baseName(const std::string& path)
|
|
{
|
|
const auto slash = path.find_last_of("/\\");
|
|
return slash == std::string::npos ? path : path.substr(slash + 1);
|
|
}
|
|
|
|
std::string toLower(std::string s)
|
|
{
|
|
std::transform(s.begin(), s.end(), s.begin(),
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
return s;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
DaemonUpdater::~DaemonUpdater()
|
|
{
|
|
cancel_requested_ = true;
|
|
if (worker_.joinable()) worker_.join();
|
|
}
|
|
|
|
void DaemonUpdater::setProgress(State state, const std::string& text, double done, double total)
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
progress_.state = state;
|
|
progress_.status_text = text;
|
|
if (done > 0) progress_.downloaded_bytes = done;
|
|
if (total > 0) progress_.total_bytes = total;
|
|
progress_.percent = (progress_.total_bytes > 0)
|
|
? static_cast<float>(100.0 * progress_.downloaded_bytes / progress_.total_bytes)
|
|
: progress_.percent;
|
|
if (state == State::Failed) progress_.error = text;
|
|
}
|
|
|
|
DaemonUpdater::Progress DaemonUpdater::getProgress() const
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
return progress_;
|
|
}
|
|
|
|
bool DaemonUpdater::onDownloadProgress(double downloadedBytes, double totalBytes)
|
|
{
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
progress_.downloaded_bytes = downloadedBytes;
|
|
if (totalBytes > 0) progress_.total_bytes = totalBytes;
|
|
progress_.percent = (progress_.total_bytes > 0)
|
|
? static_cast<float>(100.0 * progress_.downloaded_bytes / progress_.total_bytes)
|
|
: progress_.percent;
|
|
}
|
|
return !cancel_requested_.load(); // false -> curl aborts the transfer
|
|
}
|
|
|
|
bool DaemonUpdater::isDone() const
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
return progress_.state == State::Done || progress_.state == State::Failed ||
|
|
progress_.state == State::Unavailable;
|
|
}
|
|
|
|
void DaemonUpdater::cancel()
|
|
{
|
|
cancel_requested_ = true;
|
|
}
|
|
|
|
std::string DaemonUpdater::httpGet(const std::string& url)
|
|
{
|
|
CURL* curl = curl_easy_init();
|
|
if (!curl) return {};
|
|
std::string result;
|
|
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeStringCb);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &result);
|
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
|
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L);
|
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
|
const CURLcode res = curl_easy_perform(curl);
|
|
long httpCode = 0;
|
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
|
|
curl_easy_cleanup(curl);
|
|
if (res != CURLE_OK || httpCode < 200 || httpCode >= 300) {
|
|
DEBUG_LOGF("[daemon-updater] GET %s failed: %s (HTTP %ld)\n",
|
|
url.c_str(), curl_easy_strerror(res), httpCode);
|
|
return {};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
bool DaemonUpdater::downloadToFile(const std::string& url, const std::string& destPath)
|
|
{
|
|
FILE* fp = std::fopen(destPath.c_str(), "wb");
|
|
if (!fp) return false;
|
|
CURL* curl = curl_easy_init();
|
|
if (!curl) { std::fclose(fp); return false; }
|
|
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L);
|
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
|
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
|
|
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 60L);
|
|
curl_easy_setopt(curl, CURLOPT_MAXFILESIZE_LARGE, kMaxArchiveBytes); // refuse oversized bodies
|
|
// Live progress + cancellation: the callback publishes byte counts and aborts on cancel().
|
|
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
|
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferCb);
|
|
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
|
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
|
const CURLcode res = curl_easy_perform(curl);
|
|
long httpCode = 0;
|
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
|
|
curl_easy_cleanup(curl);
|
|
std::fclose(fp);
|
|
if (res != CURLE_OK || httpCode < 200 || httpCode >= 300) {
|
|
DEBUG_LOGF("[daemon-updater] download %s failed: %s (HTTP %ld)\n",
|
|
url.c_str(), curl_easy_strerror(res), httpCode);
|
|
std::error_code ec; fs::remove(destPath, ec);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void DaemonUpdater::startCheck(const std::string& installedVersion)
|
|
{
|
|
if (worker_running_.exchange(true)) return;
|
|
cancel_requested_ = false;
|
|
if (worker_.joinable()) worker_.join();
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
progress_ = Progress{};
|
|
progress_.installed_tag = installedVersion;
|
|
progress_.state = State::Checking;
|
|
progress_.status_text = "Checking for the latest node…";
|
|
}
|
|
worker_ = std::thread([this, installedVersion] { runCheck(installedVersion); worker_running_ = false; });
|
|
}
|
|
|
|
void DaemonUpdater::startInstall(const std::string& targetDir)
|
|
{
|
|
if (worker_running_.exchange(true)) return;
|
|
cancel_requested_ = false;
|
|
if (worker_.joinable()) worker_.join();
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
progress_.state = State::Downloading;
|
|
progress_.error.clear();
|
|
progress_.status_text = "Preparing…";
|
|
progress_.downloaded_bytes = 0; // clear any prior op's progress so the bar starts at 0%
|
|
progress_.total_bytes = 0;
|
|
progress_.percent = 0.0f;
|
|
}
|
|
worker_ = std::thread([this, targetDir] { runInstall(targetDir); worker_running_ = false; });
|
|
}
|
|
|
|
void DaemonUpdater::startListReleases()
|
|
{
|
|
if (worker_running_.exchange(true)) return;
|
|
cancel_requested_ = false;
|
|
if (worker_.joinable()) worker_.join();
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
progress_.state = State::Listing;
|
|
progress_.error.clear();
|
|
progress_.status_text = "Loading releases…";
|
|
progress_.downloaded_bytes = 0; // clear any prior op's progress
|
|
progress_.total_bytes = 0;
|
|
progress_.percent = 0.0f;
|
|
}
|
|
worker_ = std::thread([this] { runListReleases(); worker_running_ = false; });
|
|
}
|
|
|
|
void DaemonUpdater::startInstallRelease(const std::string& targetDir, DaemonRelease release)
|
|
{
|
|
if (worker_running_.exchange(true)) return;
|
|
cancel_requested_ = false;
|
|
if (worker_.joinable()) worker_.join();
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
progress_.state = State::Downloading;
|
|
progress_.error.clear();
|
|
progress_.status_text = "Preparing…";
|
|
progress_.downloaded_bytes = 0; // clear any prior op's progress so the bar starts at 0%
|
|
progress_.total_bytes = 0;
|
|
progress_.percent = 0.0f;
|
|
}
|
|
worker_ = std::thread([this, targetDir, release = std::move(release)] {
|
|
installResolved(targetDir, release);
|
|
worker_running_ = false;
|
|
});
|
|
}
|
|
|
|
std::vector<DaemonRelease> DaemonUpdater::getReleases() const
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
return releases_;
|
|
}
|
|
|
|
void DaemonUpdater::runListReleases()
|
|
{
|
|
const std::string body = httpGet(kReleasesUrl);
|
|
if (body.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
|
|
std::vector<DaemonRelease> list = parseDaemonReleaseList(body);
|
|
const bool empty = list.empty();
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
releases_ = std::move(list);
|
|
}
|
|
if (empty) { setProgress(State::Failed, "No releases found."); return; }
|
|
setProgress(State::ReleaseList, "Select a version to install.");
|
|
}
|
|
|
|
void DaemonUpdater::runCheck(std::string installedVersion)
|
|
{
|
|
const std::string body = httpGet(kApiUrl);
|
|
if (body.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
|
|
const DaemonRelease rel = parseDaemonRelease(body);
|
|
if (!rel.ok) { setProgress(State::Failed, rel.error.empty() ? "Invalid release data." : rel.error); return; }
|
|
|
|
const std::string token = currentDaemonPlatformToken();
|
|
const int idx = selectDaemonAsset(rel, token);
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
progress_.latest_tag = rel.tag;
|
|
progress_.installed_tag = installedVersion;
|
|
}
|
|
if (idx < 0) {
|
|
setProgress(State::Unavailable, "No node build is available for this platform (" +
|
|
(token.empty() ? "unknown" : token) + ").");
|
|
return;
|
|
}
|
|
// Compare by vN.N.N core so an installed "v1.0.2-<commit>" matches a release "v1.0.2".
|
|
const std::string instCore = daemonVersionCore(installedVersion);
|
|
const bool updateAvailable = instCore.empty() || instCore != daemonVersionCore(rel.tag);
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
progress_.update_available = updateAvailable;
|
|
}
|
|
if (updateAvailable)
|
|
setProgress(State::UpdateAvailable, "A new node version is available (" + rel.tag + ").");
|
|
else
|
|
setProgress(State::UpToDate, "The node is up to date (" + rel.tag + ").");
|
|
}
|
|
|
|
void DaemonUpdater::runInstall(std::string targetDir)
|
|
{
|
|
setProgress(State::Checking, "Checking for the latest node…");
|
|
const std::string apiBody = httpGet(kApiUrl);
|
|
if (apiBody.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
|
|
const DaemonRelease rel = parseDaemonRelease(apiBody);
|
|
if (!rel.ok) { setProgress(State::Failed, rel.error.empty() ? "Invalid release data." : rel.error); return; }
|
|
installResolved(targetDir, rel);
|
|
}
|
|
|
|
void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRelease& rel)
|
|
{
|
|
const std::string token = currentDaemonPlatformToken();
|
|
const int idx = selectDaemonAsset(rel, token);
|
|
if (idx < 0) {
|
|
setProgress(State::Unavailable, "No node build is available for this platform (" +
|
|
(token.empty() ? "unknown" : token) + ").");
|
|
return;
|
|
}
|
|
const DaemonReleaseAsset& asset = rel.assets[idx];
|
|
const auto checksums = parseDaemonChecksums(rel.body);
|
|
{
|
|
std::lock_guard<std::mutex> lk(mutex_);
|
|
progress_.latest_tag = rel.tag;
|
|
}
|
|
if (cancel_requested_) { setProgress(State::Failed, "Cancelled."); return; }
|
|
|
|
std::error_code ec;
|
|
fs::create_directories(targetDir, ec);
|
|
|
|
// 1. Download the archive.
|
|
const std::string zipPath = (fs::path(targetDir) / ".dragonx-daemon-download.zip").string();
|
|
setProgress(State::Downloading, "Downloading " + asset.name + "…", 0,
|
|
static_cast<double>(asset.size));
|
|
if (!downloadToFile(asset.downloadUrl, zipPath)) {
|
|
setProgress(State::Failed, "Download failed.");
|
|
return;
|
|
}
|
|
if (cancel_requested_) { fs::remove(zipPath, ec); setProgress(State::Failed, "Cancelled."); return; }
|
|
|
|
// 2. Verify the downloaded archive. Read it once, then (a) compare its SHA-256 to the published
|
|
// checksum and (b) verify a detached ed25519 signature over the archive bytes against the
|
|
// pinned key, so a checksum rewritten in a tampered release body is not sufficient to install.
|
|
setProgress(State::Verifying, "Verifying download…");
|
|
{
|
|
std::ifstream f(zipPath, std::ios::binary);
|
|
if (!f) {
|
|
fs::remove(zipPath, ec);
|
|
setProgress(State::Failed, "Could not read the downloaded archive.");
|
|
return;
|
|
}
|
|
const std::string bytes((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
|
if (f.bad()) {
|
|
fs::remove(zipPath, ec);
|
|
setProgress(State::Failed, "Could not read the downloaded archive.");
|
|
return;
|
|
}
|
|
|
|
// 2a. SHA-256 (integrity / transit corruption).
|
|
const std::string actual = sha256Hex(bytes.data(), bytes.size());
|
|
const auto it = checksums.find(toLower(asset.name)); // keys are lowercased
|
|
if (it == checksums.end()) {
|
|
fs::remove(zipPath, ec);
|
|
setProgress(State::Failed, "No published checksum for " + asset.name + " — refusing to install.");
|
|
return;
|
|
}
|
|
if (actual != it->second) {
|
|
fs::remove(zipPath, ec);
|
|
setProgress(State::Failed, "Archive checksum mismatch — refusing to install (possible tampering).");
|
|
return;
|
|
}
|
|
|
|
// 2b. Detached ed25519 signature (authenticity) against the pinned key.
|
|
const std::string pubKey = kDaemonSignaturePublicKeyBase64;
|
|
if (!pubKey.empty()) {
|
|
const int sigIdx = selectDaemonSignatureAsset(rel, asset.name);
|
|
if (sigIdx < 0) {
|
|
if (kDaemonRequireSignature) {
|
|
fs::remove(zipPath, ec);
|
|
setProgress(State::Failed, "No signature published for this release — refusing to install.");
|
|
return;
|
|
}
|
|
DEBUG_LOGF("[daemon-updater] no signature asset for %s; proceeding on checksum only\n",
|
|
asset.name.c_str());
|
|
} else {
|
|
setProgress(State::Verifying, "Verifying signature…");
|
|
const std::string sigContent = httpGet(rel.assets[sigIdx].downloadUrl);
|
|
if (sigContent.empty() || !verifyXmrigSignature(bytes, sigContent, pubKey)) {
|
|
fs::remove(zipPath, ec);
|
|
setProgress(State::Failed, "Signature verification failed — refusing to install.");
|
|
return;
|
|
}
|
|
}
|
|
} else if (kDaemonRequireSignature) {
|
|
fs::remove(zipPath, ec);
|
|
setProgress(State::Failed, "No signing key is pinned in this build — refusing to install.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 3. Extract the daemon binaries (flatten the versioned subdir). No per-member hash check is
|
|
// needed: the whole archive was already verified above (SHA-256 + ed25519 signature), so
|
|
// every member is authentic by transitivity. The archive also carries params/asmap, which
|
|
// this updater deliberately leaves to the wallet's own resource extraction.
|
|
setProgress(State::Extracting, "Installing node…");
|
|
const std::vector<std::string> wanted = daemonExtractBasenames(token);
|
|
const std::string daemonName = wanted.front(); // "dragonxd" / "dragonxd.exe"
|
|
|
|
mz_zip_archive zip{};
|
|
if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0)) {
|
|
fs::remove(zipPath, ec);
|
|
setProgress(State::Failed, "Could not open the downloaded archive.");
|
|
return;
|
|
}
|
|
bool daemonInstalled = false;
|
|
bool failed = false;
|
|
const int numFiles = static_cast<int>(mz_zip_reader_get_num_files(&zip));
|
|
for (int i = 0; i < numFiles && !failed; ++i) {
|
|
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;
|
|
const std::string base = baseName(st.m_filename);
|
|
if (std::find(wanted.begin(), wanted.end(), base) == wanted.end()) continue; // skip params/asmap/etc.
|
|
|
|
// Reject an implausibly large member before decompressing it into memory.
|
|
if (st.m_uncomp_size > kMaxMemberBytes) { failed = true; break; }
|
|
|
|
size_t outSize = 0;
|
|
void* mem = mz_zip_reader_extract_to_heap(&zip, i, &outSize, 0);
|
|
if (!mem) { failed = true; break; }
|
|
|
|
const std::string finalPath = (fs::path(targetDir) / base).string();
|
|
const std::string tmpPath = finalPath + ".tmp";
|
|
// Tidy any leftover from a previous update (the old binary moved aside, freed after restart).
|
|
fs::remove(finalPath + ".old", ec);
|
|
{
|
|
std::ofstream of(tmpPath, std::ios::binary | std::ios::trunc);
|
|
if (!of) { mz_free(mem); failed = true; break; }
|
|
of.write(static_cast<const char*>(mem), static_cast<std::streamsize>(outSize));
|
|
}
|
|
mz_free(mem);
|
|
|
|
// Atomic install that also works while the daemon is running: POSIX rename() replaces the
|
|
// path even if the old binary is in use (the running process keeps the unlinked inode). On
|
|
// Windows a running .exe cannot be renamed-over, so move it aside to ".old" first, then put
|
|
// the new file in place; the ".old" is cleaned up on a later update once the daemon restarts.
|
|
fs::rename(tmpPath, finalPath, ec);
|
|
if (ec) {
|
|
std::error_code mec;
|
|
fs::rename(finalPath, finalPath + ".old", mec);
|
|
ec.clear();
|
|
fs::rename(tmpPath, finalPath, ec);
|
|
}
|
|
if (ec) { fs::remove(tmpPath, ec); failed = true; break; }
|
|
|
|
#ifndef _WIN32
|
|
// Every wanted member is an executable in the daemon set — make them all runnable.
|
|
fs::permissions(finalPath,
|
|
fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec |
|
|
fs::perms::others_read | fs::perms::others_exec,
|
|
fs::perm_options::replace, ec);
|
|
#endif
|
|
if (base == daemonName) daemonInstalled = true;
|
|
}
|
|
mz_zip_reader_end(&zip);
|
|
fs::remove(zipPath, ec);
|
|
|
|
if (failed) { setProgress(State::Failed, "Could not verify/install the node binaries."); return; }
|
|
if (!daemonInstalled) { setProgress(State::Failed, "Daemon binary not found in the archive."); return; }
|
|
|
|
setProgress(State::Done, "Node installed (" + rel.tag + ").");
|
|
}
|
|
|
|
} // namespace util
|
|
} // namespace dragonx
|