213 lines
7.4 KiB
C#
213 lines
7.4 KiB
C#
|
|
using System.Collections.Concurrent;
|
||
|
|
using System.Net;
|
||
|
|
using System.Net.Security;
|
||
|
|
using System.Net.Sockets;
|
||
|
|
using System.Security.Cryptography;
|
||
|
|
using System.Security.Cryptography.X509Certificates;
|
||
|
|
using System.Text;
|
||
|
|
using System.Text.Json;
|
||
|
|
|
||
|
|
namespace PalladiumWallet.Tests.Net;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Error a handler can throw to make the fake server reply with a JSON-RPC
|
||
|
|
/// error object (e.g. code -102 "server busy" to exercise the retry path).
|
||
|
|
/// </summary>
|
||
|
|
public sealed class FakeElectrumError(int code, string message) : Exception(message)
|
||
|
|
{
|
||
|
|
public int Code { get; } = code;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// In-process ElectrumX-like server for tests: newline-delimited JSON-RPC 2.0
|
||
|
|
/// over a loopback TCP socket, optionally TLS with a self-signed certificate.
|
||
|
|
/// Handlers are registered per method and receive the request "params" array;
|
||
|
|
/// whatever they return is serialised as the "result". "server.version" is
|
||
|
|
/// pre-registered. Calls are counted per method (cache/retry assertions).
|
||
|
|
/// </summary>
|
||
|
|
public sealed class FakeElectrumServer : IAsyncDisposable
|
||
|
|
{
|
||
|
|
private readonly TcpListener _listener;
|
||
|
|
private readonly CancellationTokenSource _cts = new();
|
||
|
|
private readonly Task _acceptLoop;
|
||
|
|
private readonly X509Certificate2? _certificate;
|
||
|
|
private readonly ConcurrentDictionary<string, Func<JsonElement, object?>> _handlers = new();
|
||
|
|
private readonly ConcurrentDictionary<string, int> _callCounts = new();
|
||
|
|
private readonly ConcurrentBag<(Stream Stream, SemaphoreSlim WriteLock, TcpClient Tcp)> _clients = [];
|
||
|
|
|
||
|
|
/// <summary>Sentinel: a handler returning this leaves the request unanswered (pending forever).</summary>
|
||
|
|
public static readonly object NoResponse = new();
|
||
|
|
|
||
|
|
public FakeElectrumServer(X509Certificate2? certificate = null, int port = 0)
|
||
|
|
{
|
||
|
|
_certificate = certificate;
|
||
|
|
_listener = new TcpListener(IPAddress.Loopback, port);
|
||
|
|
_listener.Start();
|
||
|
|
Handle("server.version", _ => new[] { "FakeElectrumX 1.0", "1.4" });
|
||
|
|
_acceptLoop = Task.Run(AcceptLoopAsync);
|
||
|
|
}
|
||
|
|
|
||
|
|
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||
|
|
public string Host => "127.0.0.1";
|
||
|
|
|
||
|
|
/// <summary>Registers (or replaces) the handler for a JSON-RPC method.</summary>
|
||
|
|
public void Handle(string method, Func<JsonElement, object?> handler) =>
|
||
|
|
_handlers[method] = handler;
|
||
|
|
|
||
|
|
public int CallCount(string method) => _callCounts.GetValueOrDefault(method);
|
||
|
|
|
||
|
|
public void ResetCallCounts() => _callCounts.Clear();
|
||
|
|
|
||
|
|
/// <summary>Pushes a JSON-RPC notification to every connected client.</summary>
|
||
|
|
public async Task NotifyAsync(string method, object parameters)
|
||
|
|
{
|
||
|
|
var line = JsonSerializer.SerializeToUtf8Bytes(new
|
||
|
|
{
|
||
|
|
jsonrpc = "2.0",
|
||
|
|
method,
|
||
|
|
@params = parameters,
|
||
|
|
});
|
||
|
|
foreach (var (stream, writeLock, _) in _clients)
|
||
|
|
await WriteLineAsync(stream, writeLock, line);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Abruptly closes every accepted connection (tests the client's failure paths).</summary>
|
||
|
|
public void DropClients()
|
||
|
|
{
|
||
|
|
foreach (var (_, _, tcp) in _clients)
|
||
|
|
tcp.Close();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Self-signed certificate for TLS tests (exportable, valid now).</summary>
|
||
|
|
public static X509Certificate2 CreateSelfSignedCertificate(string cn = "fake-electrum")
|
||
|
|
{
|
||
|
|
using var rsa = RSA.Create(2048);
|
||
|
|
var request = new CertificateRequest(
|
||
|
|
$"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||
|
|
using var cert = request.CreateSelfSigned(
|
||
|
|
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30));
|
||
|
|
// PFX roundtrip so the private key is usable by SslStream on every platform.
|
||
|
|
return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), null);
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task AcceptLoopAsync()
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
while (!_cts.IsCancellationRequested)
|
||
|
|
{
|
||
|
|
var tcp = await _listener.AcceptTcpClientAsync(_cts.Token);
|
||
|
|
_ = Task.Run(() => ServeClientAsync(tcp));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (OperationCanceledException) { }
|
||
|
|
catch (SocketException) { }
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task ServeClientAsync(TcpClient tcp)
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
Stream stream = tcp.GetStream();
|
||
|
|
if (_certificate is not null)
|
||
|
|
{
|
||
|
|
var ssl = new SslStream(stream, leaveInnerStreamOpen: false);
|
||
|
|
await ssl.AuthenticateAsServerAsync(_certificate);
|
||
|
|
stream = ssl;
|
||
|
|
}
|
||
|
|
|
||
|
|
var writeLock = new SemaphoreSlim(1, 1);
|
||
|
|
_clients.Add((stream, writeLock, tcp));
|
||
|
|
|
||
|
|
using var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, leaveOpen: true);
|
||
|
|
while (!_cts.IsCancellationRequested)
|
||
|
|
{
|
||
|
|
var line = await reader.ReadLineAsync(_cts.Token);
|
||
|
|
if (line is null) break;
|
||
|
|
if (line.Length == 0) continue;
|
||
|
|
await HandleRequestAsync(stream, writeLock, line);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (Exception)
|
||
|
|
{
|
||
|
|
// Client went away or TLS failed (e.g. rejected pin): normal in tests.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task HandleRequestAsync(Stream stream, SemaphoreSlim writeLock, string line)
|
||
|
|
{
|
||
|
|
using var doc = JsonDocument.Parse(line);
|
||
|
|
var root = doc.RootElement;
|
||
|
|
var id = root.GetProperty("id").GetInt64();
|
||
|
|
var method = root.GetProperty("method").GetString()!;
|
||
|
|
var parameters = root.TryGetProperty("params", out var p) ? p : default;
|
||
|
|
|
||
|
|
_callCounts.AddOrUpdate(method, 1, (_, n) => n + 1);
|
||
|
|
|
||
|
|
byte[] payload;
|
||
|
|
if (!_handlers.TryGetValue(method, out var handler))
|
||
|
|
{
|
||
|
|
payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||
|
|
{
|
||
|
|
jsonrpc = "2.0",
|
||
|
|
id,
|
||
|
|
error = new { code = -32601, message = $"unknown method '{method}'" },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var result = handler(parameters);
|
||
|
|
if (ReferenceEquals(result, NoResponse))
|
||
|
|
return;
|
||
|
|
payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||
|
|
{
|
||
|
|
jsonrpc = "2.0",
|
||
|
|
id,
|
||
|
|
result,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
catch (FakeElectrumError err)
|
||
|
|
{
|
||
|
|
payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||
|
|
{
|
||
|
|
jsonrpc = "2.0",
|
||
|
|
id,
|
||
|
|
error = new { code = err.Code, message = err.Message },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
await WriteLineAsync(stream, writeLock, payload);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async Task WriteLineAsync(Stream stream, SemaphoreSlim writeLock, byte[] payload)
|
||
|
|
{
|
||
|
|
await writeLock.WaitAsync();
|
||
|
|
try
|
||
|
|
{
|
||
|
|
await stream.WriteAsync(payload);
|
||
|
|
stream.WriteByte((byte)'\n');
|
||
|
|
await stream.FlushAsync();
|
||
|
|
}
|
||
|
|
catch (Exception)
|
||
|
|
{
|
||
|
|
// Connection already gone: irrelevant for the test in progress.
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
writeLock.Release();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public async ValueTask DisposeAsync()
|
||
|
|
{
|
||
|
|
await _cts.CancelAsync();
|
||
|
|
_listener.Stop();
|
||
|
|
DropClients();
|
||
|
|
try { await _acceptLoop; } catch { }
|
||
|
|
_cts.Dispose();
|
||
|
|
}
|
||
|
|
}
|