Files
wowlet/src/utils/nodes.cpp
T
jwinterm 35af2db2ef wowlet: keep wownero clearnet nodes usable in Tor mode (fix empty remote-node list)
feather's websocket-node filter drops every non-onion node once the Tor privacy
level flips to onion-preferred (after initial sync) — it assumes an onion node
ecosystem. wownero's public nodes are clearnet, so the Settings list went empty
and autoConnect had nothing eligible -> Disconnected. Keep clearnet nodes
eligible in every proxy mode (routed over Tor's SOCKS proxy when Tor is on, so
still private); only hide onion/i2p nodes when their anonymity mode isn't active.
2026-06-20 06:05:47 -04:00

678 lines
21 KiB
C++

// SPDX-License-Identifier: BSD-3-Clause
// SPDX-FileCopyrightText: The Monero Project
#include "nodes.h"
#include "libwalletqt/Wallet.h"
#include "utils/AppData.h"
#include "utils/DaemonManager.h"
#include "utils/Utils.h"
#include "utils/os/tails.h"
#include "utils/os/whonix.h"
#include "constants.h"
#include "utils/WebsocketNotifier.h"
#include "utils/TorManager.h"
#include "utils/Networking.h"
#include <QJsonArray>
#include <QNetworkReply>
#include <QTimer>
bool NodeList::addNode(const QString &node, NetworkType::Type networkType, NodeList::Type source) {
// We can't obtain references to QJsonObjects...
QJsonObject obj = this->getConfigData();
this->ensureStructure(obj, networkType);
QString networkTypeStr = QString::number(networkType);
QJsonObject netTypeObj = obj.value(networkTypeStr).toObject();
QString sourceStr = Utils::QtEnumToString(source);
QJsonArray sourceArray = netTypeObj.value(sourceStr).toArray();
if (sourceArray.contains(node)) {
return false;
}
sourceArray.append(node);
netTypeObj[sourceStr] = sourceArray;
obj[networkTypeStr] = netTypeObj;
conf()->set(Config::nodes, obj);
return true;
}
void NodeList::setNodes(const QStringList &nodes, NetworkType::Type networkType, NodeList::Type source) {
QJsonObject obj = this->getConfigData();
this->ensureStructure(obj, networkType);
QString networkTypeStr = QString::number(networkType);
QJsonObject netTypeObj = obj.value(networkTypeStr).toObject();
QString sourceStr = Utils::QtEnumToString(source);
QJsonArray sourceArray = QJsonArray::fromStringList(nodes);
netTypeObj[sourceStr] = sourceArray;
obj[networkTypeStr] = netTypeObj;
conf()->set(Config::nodes, obj);
}
QStringList NodeList::getNodes(NetworkType::Type networkType, NodeList::Type source) {
QJsonObject obj = this->getConfigData();
this->ensureStructure(obj, networkType);
QString networkTypeStr = QString::number(networkType);
QJsonObject netTypeObj = obj.value(networkTypeStr).toObject();
QString sourceStr = Utils::QtEnumToString(source);
QJsonArray sourceArray = netTypeObj.value(sourceStr).toArray();
QStringList nodes;
for (const auto &node : sourceArray) {
nodes << node.toString();
}
return nodes;
}
QJsonObject NodeList::getConfigData() {
QJsonObject obj = conf()->get(Config::nodes).toJsonObject();
// Load old config format
if (obj.isEmpty()) {
auto jsonData = conf()->get(Config::nodes).toByteArray();
if (Utils::validateJSON(jsonData)) {
QJsonDocument doc = QJsonDocument::fromJson(jsonData);
obj = doc.object();
}
}
return obj;
}
void NodeList::ensureStructure(QJsonObject &obj, NetworkType::Type networkType) {
QString networkTypeStr = QString::number(networkType);
QJsonObject netTypeObj = obj.value(networkTypeStr).toObject();
if (!netTypeObj.contains("ws"))
netTypeObj["ws"] = QJsonArray();
if (!netTypeObj.contains("custom"))
netTypeObj["custom"] = QJsonArray();
obj[networkTypeStr] = netTypeObj;
}
Nodes::Nodes(QObject *parent, Wallet *wallet)
: QObject(parent)
, modelWebsocket(new NodeModel(NodeSource::websocket, this))
, modelCustom(new NodeModel(NodeSource::custom, this))
, m_connection(FeatherNode())
, m_wallet(wallet)
{
// TODO: This class is in desperate need of refactoring
this->loadConfig();
connect(websocketNotifier(), &WebsocketNotifier::NodesReceived, this, &Nodes::onWSNodesReceived);
// wowlet: the feather websocket is disabled (no WOW backend), so pull the live remote-node list from
// wownero.org/nodes instead — routed over Tor when Tor is active, with the bundled list (already loaded
// by loadConfig above) as the fallback. Refreshed hourly.
if (conf()->get(Config::disableWebsocket).toBool()) {
QTimer::singleShot(4000, this, &Nodes::fetchRemoteNodeList);
auto *refresh = new QTimer(this);
connect(refresh, &QTimer::timeout, this, &Nodes::fetchRemoteNodeList);
refresh->start(60 * 60 * 1000);
}
if (m_wallet) {
connect(m_wallet, &Wallet::walletRefreshed, this, &Nodes::onWalletRefreshed);
}
}
void Nodes::loadConfig() {
QStringList customNodes = m_nodes.getNodes(constants::networkType, NodeList::custom);
for (const auto &node : customNodes) {
FeatherNode customNode{node};
customNode.custom = true;
if (m_connection == customNode) {
if (m_connection.isActive)
customNode.isActive = true;
else if (m_connection.isConnecting)
customNode.isConnecting = true;
}
m_customNodes.append(customNode);
}
QStringList websocketNodes = m_nodes.getNodes(constants::networkType, NodeList::ws);
// wowlet: the websocket node-discovery server is disabled (no wownero equivalent), so this cached list
// never refreshes and may be stale or polluted with monero nodes from an earlier build. Drop it so the
// bundled wownero nodes.json below becomes the managed list.
if (conf()->get(Config::disableWebsocket).toBool())
websocketNodes.clear();
for (const auto &node : websocketNodes) {
FeatherNode wsNode{node};
wsNode.custom = false;
wsNode.online = true; // assume online
if (m_connection == wsNode) {
if (m_connection.isActive)
wsNode.isActive = true;
else if (m_connection.isConnecting)
wsNode.isConnecting = true;
}
m_websocketNodes.append(wsNode);
}
if (m_websocketNodes.count() > 0) {
qDebug() << QString("Loaded %1 cached websocket nodes from config").arg(m_websocketNodes.count());
}
if (m_customNodes.count() > 0) {
qDebug() << QString("Loaded %1 custom nodes from config").arg(m_customNodes.count());
}
// No nodes cached, fallback to hardcoded list
if (m_websocketNodes.count() == 0) {
QByteArray file = Utils::fileOpenQRC(":/assets/nodes.json");
QJsonDocument nodes_json = QJsonDocument::fromJson(file);
QJsonObject nodes_obj = nodes_json.object();
QString netKey;
if (constants::networkType == NetworkType::MAINNET) {
netKey = "mainnet";
} else if (constants::networkType == NetworkType::STAGENET) {
netKey = "stagenet";
}
if (nodes_obj.contains(netKey)) {
QJsonArray nodes_list;
nodes_list = nodes_json[netKey].toObject()["tor"].toArray();
for (const auto &node : nodes_json[netKey].toObject()["clearnet"].toArray()) {
nodes_list.append(node);
}
for (const auto &node : nodes_json[netKey].toObject()["i2p"].toArray()) {
nodes_list.append(node);
}
for (auto node: nodes_list) {
FeatherNode wsNode(node.toString());
wsNode.custom = false;
wsNode.online = true;
m_websocketNodes.append(wsNode);
m_nodes.addNode(node.toString(), constants::networkType, NodeList::Type::ws);
}
}
qDebug() << QString("Loaded %1 nodes from hardcoded list").arg(m_websocketNodes.count());
}
this->updateModels();
}
void Nodes::connectToNode() {
// auto connect
this->autoConnect(true);
}
void Nodes::connectToNode(const FeatherNode &node) {
if (!m_wallet) {
return;
}
if (!m_allowConnection) {
return;
}
if (!node.isValid()) {
return;
}
if (conf()->get(Config::offlineMode).toBool()) {
return;
}
if (conf()->get(Config::proxy).toInt() == Config::Proxy::Tor && conf()->get(Config::torOnlyAllowOnion).toBool()) {
if (!node.isOnion() && !node.isLocal()) {
// We only want to connect to .onion nodes, but local nodes get an exception.
return;
}
}
qInfo() << QString("Attempting to connect to %1 (%2)").arg(node.toAddress(), node.custom ? "custom" : "ws");
if (!node.url.userName().isEmpty() && !node.url.password().isEmpty()) {
m_wallet->setDaemonLogin(node.url.userName(), node.url.password());
}
// Don't use SSL over Tor/i2p
m_wallet->setUseSSL(!node.isAnonymityNetwork());
QString proxyAddress;
if (useSocks5Proxy(node)) {
if (conf()->get(Config::proxy).toInt() == Config::Proxy::Tor && (!torManager()->isLocalTor() || torManager()->isAlreadyRunning())) {
proxyAddress = QString("%1:%2").arg(torManager()->featherTorHost, QString::number(torManager()->featherTorPort));
} else {
proxyAddress = QString("%1:%2").arg(conf()->get(Config::socks5Host).toString(),
conf()->get(Config::socks5Port).toString());
}
}
m_wallet->initAsync(node.toAddress(), true, 0, proxyAddress);
m_connection = node;
m_connection.isActive = false;
m_connection.isConnecting = true;
this->resetLocalState();
this->updateModels();
}
void Nodes::autoConnect(bool forceReconnect) {
if (!m_wallet) {
return;
}
if (!m_allowConnection) {
return;
}
if (conf()->get(Config::offlineMode).toBool()) {
return;
}
// this function is responsible for automatically connecting to a daemon.
if (m_wallet == nullptr || !m_enableAutoconnect) {
return;
}
Wallet::ConnectionStatus status = m_wallet->connectionStatus();
// wowlet: when running our own embedded node, always connect to it and bypass remote node
// selection. Disabling Config::runLocalNode (the deliberate opt-out) restores remote nodes.
if (conf()->get(Config::runLocalNode).toBool()) {
if (status == Wallet::ConnectionStatus_Disconnected || forceReconnect) {
this->connectToNode(FeatherNode(daemonManager()->rpcAddress()));
}
return;
}
bool wsMode = (this->source() == NodeSource::websocket);
if (wsMode && !m_wsNodesReceived && websocketNodes().count() == 0) {
// this situation should rarely onneccur due to the usage of the websocket node cache on startup.
qInfo() << "Feather is in websocket connection mode but was not able to receive any nodes (yet).";
return;
}
if (status == Wallet::ConnectionStatus_Disconnected || forceReconnect) {
if (m_connection.isValid() && !forceReconnect) {
m_recentFailures << m_connection.toAddress();
}
// try connect
FeatherNode node = this->pickEligibleNode();
this->connectToNode(node);
return;
}
else if ((status == Wallet::ConnectionStatus_Synchronizing || status == Wallet::ConnectionStatus_Synchronized) && m_connection.isConnecting) {
qInfo() << QString("Node connected to %1").arg(m_connection.toAddress());
// set current connection object
m_connection.isConnecting = false;
m_connection.isActive = true;
// reset node exhaustion state
m_recentFailures.clear();
}
this->resetLocalState();
this->updateModels();
}
FeatherNode Nodes::pickEligibleNode() {
// Pick a node at random to connect to
auto rtn = FeatherNode();
auto wsMode = (this->source() == NodeSource::websocket);
auto nodes = wsMode ? websocketNodes() : m_customNodes;
if (nodes.count() == 0) {
if (wsMode)
this->exhausted();
return rtn;
}
QVector<int> node_indices;
int i = 0;
for (const auto &node: nodes) {
node_indices.push_back(i);
i++;
}
unsigned seed = std::chrono::system_clock::now().time_since_epoch().count();
std::shuffle(node_indices.begin(), node_indices.end(), std::default_random_engine(seed));
// Pick random eligible node
int mode_height = this->modeHeight(nodes);
for (int index : node_indices) {
const FeatherNode &node = nodes.at(index);
// This may fail to detect bad nodes if cached nodes are used
// Todo: wait on websocket before connecting, only use cache if websocket is unavailable
if (wsMode && m_wsNodesReceived) {
// Ignore offline nodes
if (!node.online)
continue;
// Ignore nodes that are more than 25 blocks behind mode
if (node.height < (mode_height - 25))
continue;
// Ignore nodes that say they aren't synchronized
if (node.target_height > node.height)
continue;
}
if (conf()->get(Config::proxy).toInt() == Config::Proxy::Tor && conf()->get(Config::torOnlyAllowOnion).toBool()) {
if (!node.isOnion() && !node.isLocal()) {
// We only want to connect to .onion nodes, but local nodes get an exception.
continue;
}
}
// Don't connect to nodes that failed to connect recently
if (m_recentFailures.contains(node.toAddress())) {
continue;
}
return node;
}
// All nodes tried, and none eligible
// Don't show node exhaustion warning if single custom node is used
if (wsMode || node_indices.size() > 1) {
this->exhausted();
}
return rtn;
}
void Nodes::onWSNodesReceived(QList<FeatherNode> &nodes) {
m_websocketNodes.clear();
m_wsNodesReceived = true;
for (auto &node: nodes) {
if (m_connection == node) {
if (m_connection.isActive)
node.isActive = true;
else if (m_connection.isConnecting)
node.isConnecting = true;
}
m_websocketNodes.push_back(node);
}
// cache into config
QStringList wsNodeList;
for (const auto &node : m_websocketNodes) {
wsNodeList << node.toAddress();
}
m_nodes.setNodes(wsNodeList, constants::networkType, NodeList::ws);
this->resetLocalState();
this->updateModels();
}
void Nodes::fetchRemoteNodeList() {
if (conf()->get(Config::offlineMode).toBool())
return;
if (!m_wowNet)
m_wowNet = new Networking(this);
QNetworkReply *reply = m_wowNet->getJson(this, "https://wownero.org/nodes/api/nodes/wallet");
if (!reply)
return;
connect(reply, &QNetworkReply::finished, this, [this, reply] {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "wownero node feed unreachable, keeping bundled list:" << reply->errorString();
return;
}
QJsonObject root = QJsonDocument::fromJson(reply->readAll()).object();
const int best = root.value("height_best").toInt();
QList<FeatherNode> nodes;
for (const auto &v : root.value("nodes").toArray()) {
QJsonObject o = v.toObject();
// wowlet talks plain HTTP to daemons (over Tor); skip SSL-only nodes it can't reach.
if (o.value("scheme").toString() == "https")
continue;
const QString host = o.value("host").toString();
if (host.isEmpty())
continue;
const QString addr = QString("%1:%2").arg(host, QString::number(o.value("port").toInt()));
nodes.append(FeatherNode(addr, o.value("height").toInt(), best, true));
}
if (nodes.isEmpty()) {
qWarning() << "wownero node feed returned no usable (http) nodes; keeping bundled list";
return;
}
qInfo() << "wownero node feed: loaded" << nodes.count() << "remote nodes";
this->onWSNodesReceived(nodes);
});
}
void Nodes::onNodeSourceChanged(NodeSource nodeSource) {
this->resetLocalState();
this->updateModels();
this->connectToNode();
}
void Nodes::setCustomNodes(const QList<FeatherNode> &nodes) {
m_customNodes.clear();
QStringList nodesList;
for (auto const &node: nodes) {
if (nodesList.contains(node.toFullAddress())) // skip duplicates
continue;
nodesList.append(node.toFullAddress());
m_customNodes.append(node);
}
m_nodes.setNodes(nodesList, constants::networkType, NodeList::Type::custom);
this->resetLocalState();
this->updateModels();
}
void Nodes::onWalletRefreshed() {
if (conf()->get(Config::proxy) == Config::Proxy::Tor && conf()->get(Config::torPrivacyLevel).toInt() == Config::allTorExceptInitSync) {
// Don't reconnect if we're connected to a local node (traffic will not be routed through Tor)
if (m_connection.isLocal())
return;
// Don't reconnect if we're already connected to an .onion node
if (m_connection.isOnion())
return;
this->autoConnect(true);
}
}
bool Nodes::useOnionNodes() {
if (conf()->get(Config::proxy) != Config::Proxy::Tor) {
return false;
}
if (conf()->get(Config::torOnlyAllowOnion).toBool()) {
return true;
}
auto privacyLevel = conf()->get(Config::torPrivacyLevel).toInt();
if (privacyLevel == Config::allTor) {
return true;
}
if (privacyLevel == Config::allTorExceptInitSync) {
if (m_wallet && m_wallet->refreshedOnce) {
return true;
}
if (appData()->heights.contains(constants::networkType)) {
int initSyncThreshold = conf()->get(Config::initSyncThreshold).toInt();
int networkHeight = appData()->heights[constants::networkType];
if (m_wallet && m_wallet->blockChainHeight() > (networkHeight - initSyncThreshold)) {
return true;
}
}
}
return false;
}
bool Nodes::useI2PNodes() {
if (conf()->get(Config::proxy) == Config::Proxy::i2p) {
return true;
}
return false;
}
bool Nodes::useSocks5Proxy(const FeatherNode &node) {
if (node.isLocal()) {
// When Feather is connected to a local node, ignore proxy settings.
return false;
}
if (Utils::isTorsocks()) {
// When Feather is started with torsocks, let torsocks handle traffic.
return false;
}
const auto config_proxy = conf()->get(Config::proxy).toInt();
if (config_proxy == Config::Proxy::None) {
return false;
}
if (TailsOS::detect()) {
// Tails does not transparently route traffic over Tor.
return true;
}
if (WhonixOS::detect()) {
return true;
}
if (config_proxy == Config::Proxy::Tor) {
// Don't use socks5 proxy if initial sync traffic is excluded.
return this->useOnionNodes();
}
return true;
}
void Nodes::updateModels() {
this->modelCustom->updateNodes(m_customNodes);
this->modelWebsocket->updateNodes(this->websocketNodes());
}
void Nodes::resetLocalState() {
auto resetState = [this](QList<FeatherNode> &model){
for (auto &node: model) {
node.isConnecting = false;
node.isActive = false;
if (node == m_connection) {
node.isActive = m_connection.isActive;
node.isConnecting = m_connection.isConnecting;
}
}
};
resetState(m_customNodes);
resetState(m_websocketNodes);
}
void Nodes::exhausted() {
// Do nothing
}
QList<FeatherNode> Nodes::nodes() {
// Return current node list
return (this->source() == NodeSource::websocket) ? websocketNodes() : m_customNodes;
}
QList<FeatherNode> Nodes::customNodes() {
return m_customNodes;
}
QList<FeatherNode> Nodes::websocketNodes() {
bool onionNode = this->useOnionNodes();
bool i2pNode = this->useI2PNodes();
QList<FeatherNode> nodes;
for (const auto &node : m_websocketNodes) {
// wowlet: wownero's public nodes are clearnet (there is effectively no onion node ecosystem), so
// keep clearnet nodes eligible in EVERY proxy mode — they're routed over Tor's SOCKS proxy when Tor
// is enabled, so still private; just not onion-only. Only hide onion/i2p nodes when the matching
// anonymity-network mode isn't active.
if (!onionNode && node.isOnion()) {
continue;
}
if (!i2pNode && node.isI2P()) {
continue;
}
nodes.push_back(node);
}
return nodes;
}
FeatherNode Nodes::connection() {
return m_connection;
}
NodeSource Nodes::source() {
return static_cast<NodeSource>(conf()->get(Config::nodeSource).toInt());
}
int Nodes::modeHeight(const QList<FeatherNode> &nodes) {
QVector<int> heights;
for (const auto &node: nodes) {
heights.push_back(node.height);
}
std::sort(heights.begin(), heights.end());
int max_count = 1, mode_height = heights[0], count = 1;
for (int i = 1; i < heights.count(); i++) {
if (heights[i] == 0) { // Don't consider 0 height nodes
continue;
}
if (heights[i] == heights[i - 1])
count++;
else {
if (count > max_count) {
max_count = count;
mode_height = heights[i - 1];
}
count = 1;
}
}
if (count > max_count)
{
mode_height = heights[heights.count() - 1];
}
return mode_height;
}
void Nodes::allowConnection() {
m_allowConnection = true;
}
Nodes::~Nodes() = default;