Create BestEffortEndPointConnector class
MODIFY
WalletWasabi.Gui/Global.cs
WalletWasabi.Gui/Global.cs
@@ -262,17 +262,15 @@ namespace WalletWasabi.Gui
}
else
{
+ var bestEffortEndpointConnector = new BestEffortEndpointConnector();
+ connectionParameters.EndpointConnector = bestEffortEndpointConnector;
if (Config.UseTor)
{
- // onlyForOnionHosts: false - Connect to clearnet IPs through Tor, too.
connectionParameters.TemplateBehaviors.Add(new SocksSettingsBehavior(Config.TorSocks5EndPoint, onlyForOnionHosts: false, networkCredential: null, streamIsolation: true));
- // allowOnlyTorEndpoints: true - Connect only to onions and do not connect to clearnet IPs at all.
- // This of course makes the first setting unnecessary, but it's better if that's around, in case someone wants to tinker here.
- connectionParameters.EndpointConnector = new DefaultEndpointConnector(allowOnlyTorEndpoints: Network == Network.Main);
-
- await AddKnownBitcoinFullNodeAsHiddenServiceAsync(AddressManager).ConfigureAwait(false);
}
Nodes = new NodesGroup(Network, connectionParameters, requirements: Constants.NodeRequirements);
+ Nodes.ConnectedNodes.Added += ConnectedNodes_OnAddedOrRemoved;
+ Nodes.ConnectedNodes.Removed += ConnectedNodes_OnAddedOrRemoved;
Nodes.MaximumNodeConnection = 12;
RegTestMempoolServingNode = null;
}
@@ -416,25 +414,13 @@ namespace WalletWasabi.Gui
return addressManagerBehavior;
}
- private async Task AddKnownBitcoinFullNodeAsHiddenServiceAsync(AddressManager addressManager)
- {
- if (Network == Network.RegTest)
+ private void ConnectedNodes_OnAddedOrRemoved(object? sender, NodeEventArgs e)
{
- return;
- }
-
- // curl -s https://bitnodes.21.co/api/v1/snapshots/latest/ | egrep -o '[a-z0-9]{16}\.onion:?[0-9]*' | sort -ru
- // Then filtered to include only /Satoshi:0.17.x
- var fullBaseDirectory = EnvironmentHelpers.GetFullBaseDirectory();
-
- var onions = await File.ReadAllLinesAsync(Path.Combine(fullBaseDirectory, "Tor", "OnionSeeds", $"{Network}OnionSeeds.txt")).ConfigureAwait(false);
-
- onions.Shuffle();
- foreach (var onion in onions.Take(60))
+ if (Nodes.NodeConnectionParameters.EndpointConnector is BestEffortEndpointConnector bestEffortEndPointConnector)
{
- if (EndPointParser.TryParse(onion, Network.DefaultPort, out var endpoint))
+ if (sender is NodesCollection nodesCollection)
{
- await addressManager.AddAsync(endpoint).ConfigureAwait(false);
+ bestEffortEndPointConnector.UpdateConnectedNodesCounter(nodesCollection.Count);
}
}
}
@@ -680,6 +666,8 @@ namespace WalletWasabi.Gui
if (Nodes is { } nodes)
{
+ Nodes.ConnectedNodes.Added -= ConnectedNodes_OnAddedOrRemoved;
+ Nodes.ConnectedNodes.Removed -= ConnectedNodes_OnAddedOrRemoved;
nodes.Disconnect();
while (nodes.ConnectedNodes.Any(x => x.IsConnected))
{

ADD
WalletWasabi.Tests/UnitTests/BestEffortEndpointConnectorTests.cs
+using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+using NBitcoin;
+using NBitcoin.Protocol;
+using NBitcoin.Protocol.Behaviors;
+using NBitcoin.Protocol.Connectors;
+using Xunit;
+
+namespace WalletWasabi.Tests.UnitTests
+{
+ public class BestEffortEndpointConnectorTests
+ {
+ [Fact]
+ public async Task CanConnectWithDifferentModesAsync()
+ {
+ var connector = new BestEffortEndpointConnector();
+ var nodeConnectionParameters = new NodeConnectionParameters();
+ nodeConnectionParameters.TemplateBehaviors.Add(new SocksSettingsBehavior(new IPEndPoint(IPAddress.Loopback, 8090), onlyForOnionHosts: true, networkCredential: null, streamIsolation: true));
+
+ using var nodes = new NodesGroup(Network.TestNet, nodeConnectionParameters);
+
+ async Task Connect(EndPoint endpoint)
+ {
+ using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
+ await connector.ConnectSocket(socket, endpoint, nodeConnectionParameters, CancellationToken.None);
+ }
+
+ Exception ex;
+ // Try to connect to a non-onion address
+ ex = await Assert.ThrowsAsync<InvalidOperationException>(
+ async () => await Connect(new IPEndPoint(IPAddress.Loopback, 180)));
+ Assert.Equal("The Endpoint connector is configured to allow only Tor endpoints and the '127.0.0.1:180' enpoint is not one", ex.Message);
+ Assert.Equal(BestEffortEndpointConnector.ConnectionMode.OnionServiceOnly, connector.State.Mode);
+
+ // Try to connect to an onion address (it has to fail because there is no a real socks proxy listening)
+ ex = await Assert.ThrowsAnyAsync<SocketException>(
+ async () => await Connect(new DnsEndPoint("nec4kn4ghql7p7an.onion", 80)));
+ Assert.Contains("refused", ex.Message);
+ Assert.Equal(BestEffortEndpointConnector.ConnectionMode.OnionServiceOnly, connector.State.Mode);
+
+ // Timing out - Mode changes from OnionServiceOnly -> AllowGoingThroughTorExitNodes
+ connector.State.LastConnectionTime = DateTimeOffset.UtcNow.AddHours(-1);
+
+ ex = await Assert.ThrowsAnyAsync<SocketException>(
+ async () => await Connect(new IPEndPoint(IPAddress.Loopback, 180)));
+ Assert.Contains("refused", ex.Message);
+ Assert.Equal(BestEffortEndpointConnector.ConnectionMode.AllowGoingThroughTorExitNodes, connector.State.Mode);
+
+ // Simulate there is a connection made (so, it is feasible to have more)
+ connector.UpdateConnectedNodesCounter(1);
+ connector.State.LastConnectionTime = DateTimeOffset.UtcNow.AddHours(-1);
+
+ ex = await Assert.ThrowsAnyAsync<SocketException>(
+ async () => await Connect(new IPEndPoint(IPAddress.Loopback, 180)));
+ Assert.Contains("refused", ex.Message);
+ Assert.Equal(BestEffortEndpointConnector.ConnectionMode.AllowGoingThroughTorExitNodes, connector.State.Mode);
+
+ // Simulate we lost connection
+ connector.State.ConnectedNodesCount = 0;
+
+ ex = await Assert.ThrowsAnyAsync<SocketException>(
+ async () => await Connect(new DnsEndPoint("nec4kn4ghql7p7an.onion", 180)));
+ Assert.Contains("refused", ex.Message);
+ Assert.Equal(BestEffortEndpointConnector.ConnectionMode.OnionServiceOnly, connector.State.Mode);
+
+ // Simulate there are no connections made
+ connector.State.LastConnectionTime = DateTimeOffset.UtcNow.AddHours(-1);
+
+ ex = await Assert.ThrowsAnyAsync<SocketException>(
+ async () => await Connect(new IPEndPoint(IPAddress.Loopback, 180)));
+ Assert.Contains("refused", ex.Message);
+ Assert.Equal(BestEffortEndpointConnector.ConnectionMode.AllowGoingThroughTorExitNodes, connector.State.Mode);
+
+ // Simulate there are no connections made (so, go to desperate mode and connect to clearnet)
+ ex = await Assert.ThrowsAnyAsync<SocketException>(
+ async () => await Connect(new IPEndPoint(IPAddress.Loopback, 180)));
+ Assert.Contains("refused", ex.Message);
+ Assert.Equal(BestEffortEndpointConnector.ConnectionMode.ClearNet, connector.State.Mode);
+ }
+ }
+}

ADD
WalletWasabi/BestEffortEndpointConnector.cs
+using NBitcoin.Protocol;
+using NBitcoin.Protocol.Behaviors;
+using NBitcoin.Protocol.Connectors;
+using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+using WalletWasabi.Logging;
+
+namespace WalletWasabi
+{
+ public class BestEffortEndpointConnector : IEnpointConnector
+ {
+ public enum ConnectionMode
+ {
+ // Connect to nodes published as onion services only
+ OnionServiceOnly,
+ // Connect to nodes published on the clearnet through Tor exit nodes
+ AllowGoingThroughTorExitNodes,
+ // Connect to nodes directly on clearnet
+ ClearNet
+ }
+
+ // A class to share state between all the BestEffortEndpointConnector connectors.
+ // This is necessary because NBitcoin clones the connectors for every new node connection
+ // attempt using the original connector.
+ public class EffortState
+ {
+ private long _connectedNodesCount;
+ private DateTimeOffset _lastModeChangeTime;
+ private bool _lostConnection;
+ public long ConnectedNodesCount
+ {
+ get => _connectedNodesCount;
+ set
+ {
+ _lostConnection = value == 0 && _connectedNodesCount > 0;
+ _connectedNodesCount = value;
+ }
+ }
+
+ public ConnectionMode Mode { get; set; }
+ public DateTimeOffset LastModeChangeTime
+ {
+ get => _lastModeChangeTime;
+ set
+ {
+ _lostConnection = false;
+ _lastModeChangeTime = value;
+ }
+ }
+
+ public TimeSpan ElapsedTimeSinceLastModeChange => DateTimeOffset.UtcNow - LastModeChangeTime;
+
+ public EffortState(ConnectionMode mode, DateTimeOffset lastModeChangeTime)
+ {
+ Mode = mode;
+ LastModeChangeTime = lastModeChangeTime;
+ }
+
+ public bool CheckModeUpdate()
+ {
+ var previousMode = Mode;
+ if (_lostConnection)
+ {
+ Mode = ConnectionMode.OnionServiceOnly;
+ }
+ else if ( (ElapsedTimeSinceLastModeChange > TimeSpan.FromMinutes(1) && ConnectedNodesCount == 0)
+ || (ElapsedTimeSinceLastModeChange > TimeSpan.FromMinutes(2) && ConnectedNodesCount <= 3)
+ || (ElapsedTimeSinceLastModeChange > TimeSpan.FromMinutes(3) && ConnectedNodesCount <= 5))
+ {
+ Mode = Mode switch {
+ ConnectionMode.OnionServiceOnly => ConnectionMode.AllowGoingThroughTorExitNodes,
+ ConnectionMode.AllowGoingThroughTorExitNodes => ConnectionMode.ClearNet,
+ ConnectionMode.ClearNet => ConnectionMode.ClearNet,
+ _ => throw new ArgumentException($"Unknown {nameof(Mode)} value {Mode}.")
+ };
+ }
+ if (previousMode != Mode)
+ {
+ Logger.LogInfo($"Update connection mode from {previousMode} to {Mode}.");
+ LastModeChangeTime = DateTimeOffset.UtcNow;
+ return true;
+ }
+ return false;
+ }
+ }
+
+ public DefaultEndpointConnector Connector { get; private set; }
+ public EffortState State { get; private set; }
+
+ public BestEffortEndpointConnector()
+ : this(
+ new DefaultEndpointConnector(allowOnlyTorEndpoints: true),
+ new EffortState(ConnectionMode.OnionServiceOnly, DateTimeOffset.UtcNow))
+ {
+ }
+
+ private BestEffortEndpointConnector(DefaultEndpointConnector connector, EffortState state)
+ {
+ Connector = connector;
+ State = state;
+ }
+
+ public void UpdateConnectedNodesCounter(int connectedNodes)
+ {
+ State.ConnectedNodesCount = connectedNodes;
+ }
+
+ public IEnpointConnector Clone()
+ {
+ return new BestEffortEndpointConnector(Connector, State);
+ }
+
+ public virtual async Task ConnectSocket(Socket socket, EndPoint endPoint, NodeConnectionParameters nodeConnectionParameters, CancellationToken cancellationToken)
+ {
+ if (State.CheckModeUpdate())
+ {
+ var socksSettings = nodeConnectionParameters.TemplateBehaviors.Find<SocksSettingsBehavior>();
+
+ switch (State.Mode)
+ {
+ case ConnectionMode.OnionServiceOnly:
+ Connector.AllowOnlyTorEndpoints = true; // throw if endPoint is not a Tor endpoint
+ if (socksSettings is { })
+ {
+ socksSettings.OnlyForOnionHosts = false; // go through Tor always
+ }
+ break;
+ case ConnectionMode.AllowGoingThroughTorExitNodes:
+ Connector.AllowOnlyTorEndpoints = false; // do not throw if endPoint is not a Tor endpoint
+ if (socksSettings is { })
+ {
+ socksSettings.OnlyForOnionHosts = false; // go through Tor always
+ }
+ break;
+ case ConnectionMode.ClearNet:
+ Connector.AllowOnlyTorEndpoints = false; // do not throw if endPoint is not a Tor endpoint
+ if (socksSettings is { })
+ {
+ socksSettings.OnlyForOnionHosts = true; // go through Tor always
+ }
+ break;
+ }
+ }
+
+ await Connector.ConnectSocket(socket, endPoint, nodeConnectionParameters, cancellationToken).ConfigureAwait(false);
+ State.LastModeChangeTime = DateTimeOffset.UtcNow;
+ }
+ }
+}

Create BestEffortEndPointConnector class