feat(updater): in-app dragonxd updater + browse-all-releases

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>
This commit is contained in:
2026-06-27 21:27:13 -05:00
parent 2e8e214689
commit 4473e7e00a
19 changed files with 1848 additions and 108 deletions

View File

@@ -23,6 +23,7 @@
#include "util/payment_uri.h"
#include "util/platform.h"
#include "util/xmrig_updater.h"
#include "util/daemon_updater.h"
#include "util/lite_server_probe.h"
#include "wallet/lite_connection_service.h"
#include "wallet/lite_diagnostics.h"
@@ -4604,6 +4605,190 @@ void testXmrigSignatureVerify()
EXPECT_FALSE(verifyXmrigSignature(data, "", pkB64));
}
// Reads any fixture file by repo-relative path under tests/fixtures/.
static std::string readFixtureFile(const std::string& rel)
{
std::ifstream f(std::string(DRAGONX_TEST_FIXTURE_DIR) + "/" + rel, std::ios::binary);
return std::string((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
}
void testXmrigReleaseListParsing()
{
using namespace dragonx::util;
const auto list = parseXmrigReleaseList(readFixtureFile("xmrig/releases_list.json"));
EXPECT_TRUE(list.size() >= 2);
EXPECT_EQ(list.front().tag, std::string("v6.25.3")); // newest first (Gitea order preserved)
for (const auto& r : list) {
EXPECT_TRUE(r.ok);
EXPECT_TRUE(!r.tag.empty());
EXPECT_TRUE(!r.assets.empty());
}
// Fail closed on non-array / garbage.
EXPECT_TRUE(parseXmrigReleaseList("not json").empty());
EXPECT_TRUE(parseXmrigReleaseList("{}").empty());
// Drafts are skipped; the pre-release flag is captured.
const std::string inj = R"([
{"tag_name":"v9.9.9","prerelease":true,"assets":[{"name":"a-linux-x64.zip","browser_download_url":"https://x/a","size":1}]},
{"tag_name":"v9.9.8","draft":true,"assets":[]}
])";
const auto injList = parseXmrigReleaseList(inj);
EXPECT_EQ(injList.size(), static_cast<std::size_t>(1)); // draft dropped
EXPECT_EQ(injList.front().tag, std::string("v9.9.9"));
EXPECT_TRUE(injList.front().prerelease);
}
// ── daemon updater pure core (util/daemon_updater.h) — driven by the real v1.0.2 release fixture ──
static std::string readDaemonFixture()
{
return readFixtureFile("daemon/release_latest.json");
}
void testDaemonReleaseParsing()
{
using namespace dragonx::util;
const auto rel = parseDaemonRelease(readDaemonFixture());
EXPECT_TRUE(rel.ok);
EXPECT_EQ(rel.tag, std::string("v1.0.2"));
EXPECT_EQ(rel.assets.size(), static_cast<std::size_t>(3)); // linux-amd64 / macos / win64
for (const auto& a : rel.assets) {
EXPECT_TRUE(!a.name.empty());
EXPECT_TRUE(a.downloadUrl.rfind("https://", 0) == 0);
EXPECT_TRUE(a.size > 0);
}
EXPECT_FALSE(parseDaemonRelease("not json at all").ok);
EXPECT_FALSE(parseDaemonRelease("{}").ok); // no tag_name
}
void testDaemonAssetSelection()
{
using namespace dragonx::util;
const auto rel = parseDaemonRelease(readDaemonFixture());
const int lin = selectDaemonAsset(rel, "linux-amd64");
const int mac = selectDaemonAsset(rel, "macos");
const int win = selectDaemonAsset(rel, "win64");
EXPECT_TRUE(lin >= 0 && mac >= 0 && win >= 0);
EXPECT_TRUE(lin != mac && mac != win && lin != win);
EXPECT_TRUE(rel.assets[lin].name.find("linux-amd64.zip") != std::string::npos);
EXPECT_TRUE(rel.assets[mac].name.find("macos.zip") != std::string::npos);
EXPECT_TRUE(rel.assets[win].name.find("win64.zip") != std::string::npos);
// Wrong/foreign tokens (e.g. the miner's naming) must NOT match the daemon archives.
EXPECT_EQ(selectDaemonAsset(rel, "linux-x64"), -1);
EXPECT_EQ(selectDaemonAsset(rel, "linux-arm64"), -1);
EXPECT_EQ(selectDaemonAsset(rel, ""), -1);
}
void testDaemonChecksumParsing()
{
using namespace dragonx::util;
const auto rel = parseDaemonRelease(readDaemonFixture());
const auto sums = parseDaemonChecksums(rel.body);
// The markdown checksum table ( "| <archive>.zip | `<sha256>` |" ) is parsed by archive name.
EXPECT_EQ(sums.at("dragonx-1.0.2-linux-amd64.zip"),
std::string("85f1dd908bfbdee6aaebabdc74848dc8963d45e7510172d84d0230c0fad6cc16"));
EXPECT_EQ(sums.at("dragonx-1.0.2-macos.zip"),
std::string("102e1f1ecab05def25465ad4867b19d46c7cc00a9fbb018ffb405bcb82cc6432"));
EXPECT_EQ(sums.at("dragonx-1.0.2-win64.zip"),
std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
// Header/separator/prose rows are ignored.
EXPECT_TRUE(parseDaemonChecksums("| File | SHA-256 |\n|---|---|\njust prose, no hashes").empty());
// Keys are lowercased so the (also-lowercased) lookup is case-insensitive vs the JSON names.
const auto mixed = parseDaemonChecksums(
"| DragonX-1.0.2-Win64.ZIP | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |");
EXPECT_EQ(mixed.at("dragonx-1.0.2-win64.zip"),
std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
}
void testDaemonBasenamesAndVersionCore()
{
using namespace dragonx::util;
const auto posix = daemonExtractBasenames("linux-amd64");
EXPECT_EQ(posix.size(), static_cast<std::size_t>(3));
EXPECT_EQ(posix.front(), std::string("dragonxd")); // daemon binary is always first
EXPECT_TRUE(std::find(posix.begin(), posix.end(), std::string("dragonx-cli")) != posix.end());
EXPECT_TRUE(std::find(posix.begin(), posix.end(), std::string("dragonx-tx")) != posix.end());
const auto win = daemonExtractBasenames("win64");
EXPECT_EQ(win.front(), std::string("dragonxd.exe"));
EXPECT_TRUE(std::find(win.begin(), win.end(), std::string("dragonx-cli.exe")) != win.end());
// Version-core normalization: a scanned "vX.Y.Z-<commit>" must compare equal to a release tag.
EXPECT_EQ(daemonVersionCore("v1.0.2-ddd851dc1"), std::string("v1.0.2"));
EXPECT_EQ(daemonVersionCore("v1.0.2"), std::string("v1.0.2"));
EXPECT_EQ(daemonVersionCore("1.0.2"), std::string("1.0.2"));
EXPECT_EQ(daemonVersionCore("DragonX v1.0.2 (abc)"), std::string("v1.0.2"));
EXPECT_EQ(daemonVersionCore("v1.2"), std::string("v1.2")); // not full N.N.N -> unchanged
EXPECT_EQ(daemonVersionCore(""), std::string(""));
EXPECT_EQ(daemonVersionCore("nightly"), std::string("nightly"));
}
void testDaemonSignatureAssetSelection()
{
using namespace dragonx::util;
DaemonRelease rel; rel.ok = true; rel.tag = "v1.0.2";
rel.assets.push_back({"dragonx-1.0.2-linux-amd64.zip", "https://x/zip", 100});
EXPECT_EQ(selectDaemonSignatureAsset(rel, "dragonx-1.0.2-linux-amd64.zip"), -1); // none published
rel.assets.push_back({"dragonx-1.0.2-linux-amd64.zip.sig", "https://x/sig", 64});
EXPECT_TRUE(selectDaemonSignatureAsset(rel, "dragonx-1.0.2-linux-amd64.zip") >= 0);
EXPECT_EQ(selectDaemonSignatureAsset(rel, "other.zip"), -1);
}
void testDaemonPinnedKeyValidity()
{
using namespace dragonx::util;
// The daemon updater REQUIRES signatures, so the pinned key must be a valid 32-byte ed25519 key.
const std::string pin = kDaemonSignaturePublicKeyBase64;
EXPECT_TRUE(kDaemonRequireSignature);
EXPECT_TRUE(!pin.empty());
EXPECT_TRUE(sodium_init() >= 0);
unsigned char pk[crypto_sign_PUBLICKEYBYTES];
std::size_t n = 0; const char* end = nullptr;
const int rc = sodium_base642bin(pk, sizeof(pk), pin.data(), pin.size(), " \t\r\n",
&n, &end, sodium_base64_VARIANT_ORIGINAL);
EXPECT_EQ(rc, 0);
EXPECT_EQ(n, static_cast<std::size_t>(crypto_sign_PUBLICKEYBYTES));
}
void testDaemonSignatureInterop()
{
using namespace dragonx::util;
// Known answer produced by scripts/sign-daemon-release.sh (OpenSSL ed25519) over the message
// below with the pinned key's secret half. Proves the pinned key + the release-signing flow
// produce signatures the wallet's libsodium verifier accepts (closes the OpenSSL<->libsodium
// interop loop), and that the pinned public key actually matches the signing key.
const std::string msg = "dragonx daemon archive payload bytes for signing test";
const std::string sigB64 =
"rmQ5qOw+W8vu56GeZrooD7Wh1N/WHRP4siD19Mxq/8WXQQuNrFY3DPCNU9C7jHB2jg/VfKrLVna57K/lkSDBDA==";
const std::string pin = kDaemonSignaturePublicKeyBase64;
EXPECT_TRUE(verifyXmrigSignature(msg, sigB64, pin));
EXPECT_TRUE(verifyXmrigSignature(msg, " " + sigB64 + "\n", pin)); // trailing newline tolerated
// Fails closed on tampered payload or wrong key.
EXPECT_FALSE(verifyXmrigSignature(msg + "x", sigB64, pin));
EXPECT_FALSE(verifyXmrigSignature(msg, sigB64, std::string(kXmrigSignaturePublicKeyBase64)));
}
void testDaemonReleaseListParsing()
{
using namespace dragonx::util;
const auto list = parseDaemonReleaseList(readFixtureFile("daemon/releases_list.json"));
EXPECT_TRUE(list.size() >= 2);
EXPECT_EQ(list.front().tag, std::string("v1.0.2")); // newest first
for (const auto& r : list) {
EXPECT_TRUE(r.ok);
EXPECT_TRUE(!r.assets.empty());
}
EXPECT_TRUE(parseDaemonReleaseList("not json").empty());
EXPECT_TRUE(parseDaemonReleaseList("{}").empty());
// Draft skipped; pre-release + name + published_at captured.
const std::string inj = R"([
{"tag_name":"v2.0.0","prerelease":true,"name":"RC","published_at":"2026-09-01T00:00:00Z","assets":[{"name":"dragonx-2.0.0-linux-amd64.zip","browser_download_url":"https://x/a","size":1}]},
{"tag_name":"v1.9.9","draft":true,"assets":[]}
])";
const auto injList = parseDaemonReleaseList(inj);
EXPECT_EQ(injList.size(), static_cast<std::size_t>(1));
EXPECT_TRUE(injList.front().prerelease);
EXPECT_EQ(injList.front().name, std::string("RC"));
EXPECT_EQ(injList.front().publishedAt.substr(0, 10), std::string("2026-09-01"));
}
// Live end-to-end exercise of the XmrigUpdater WORKER (real network + curl + miniz). Env-gated so
// CI / offline runs skip it; run with DRAGONX_TEST_NETWORK=1 to hit git.dragonx.is. Verifies the
// full download -> archive-checksum -> extract/flatten -> inner-binary-checksum -> install path.
@@ -4707,6 +4892,15 @@ int main()
testXmrigSignatureAssetSelection();
testXmrigPinnedKeyValidity();
testXmrigSignatureVerify();
testXmrigReleaseListParsing();
testDaemonReleaseParsing();
testDaemonAssetSelection();
testDaemonChecksumParsing();
testDaemonBasenamesAndVersionCore();
testDaemonSignatureAssetSelection();
testDaemonPinnedKeyValidity();
testDaemonSignatureInterop();
testDaemonReleaseListParsing();
testLiteServerHostParsing();
testLiteOfficialServerDetection();
testAtomicFileWrite();