From e7e7fab1a99c0478a3fc2bf4cc812e661594a395 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 9 Dec 2024 18:14:40 +0900 Subject: [PATCH] Plugins: Add a way for LightningClient to validate the connection string asynchronously --- BTCPayServer/Extensions.cs | 114 ++++++++++++++++-- .../Lightning/IExtendedLightningClient.cs | 25 ++++ .../Lightning/LightningLikePaymentHandler.cs | 17 ++- .../Payments/Lightning/LightningListener.cs | 21 ++-- BTCPayServer/Views/UIStores/Lightning.cshtml | 4 +- .../Views/UIStores/LightningSettings.cshtml | 4 +- 6 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 BTCPayServer/Payments/Lightning/IExtendedLightningClient.cs diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index 523e5129d..ce100e322 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -217,17 +217,25 @@ namespace BTCPayServer return endpoint != null; } - public static Uri GetServerUri(this ILightningClient client) + [Obsolete("Use GetServerUri(this ILightningClient client, string connectionString) instead")] + public static Uri GetServerUri(this ILightningClient client) => GetServerUri(client, client.ToString()); + public static Uri GetServerUri(this ILightningClient client, string connectionString) { - var kv = LightningConnectionStringHelper.ExtractValues(client.ToString(), out _); - + if (client is IExtendedLightningClient { ServerUri: { } uri }) + return uri; + var kv = client.ExtractValues(connectionString); return !kv.TryGetValue("server", out var server) ? null : new Uri(server, UriKind.Absolute); } - public static string GetDisplayName(this ILightningClient client) + [Obsolete("Use GetDisplayName(this ILightningClient client, string connectionString) instead")] + public static string GetDisplayName(this ILightningClient client) => GetDisplayName(client, client.ToString()); + public static string GetDisplayName(this ILightningClient client, string connectionString) { - LightningConnectionStringHelper.ExtractValues(client.ToString(), out var type); - + if (client is IExtendedLightningClient { DisplayName: { } displayName }) + return displayName; + var kv = client.ExtractValues(connectionString); + if (!kv.TryGetValue("type", out var type)) + return "???"; var lncType = typeof(LightningConnectionType); var fields = lncType.GetFields(BindingFlags.Public | BindingFlags.Static); var field = fields.FirstOrDefault(f => f.GetValue(lncType)?.ToString() == type); @@ -236,9 +244,96 @@ namespace BTCPayServer return attr?.Name ?? type; } - public static bool IsSafe(this ILightningClient client) + private static bool TryParseLegacy(string str, out Dictionary connectionString) { - var kv = LightningConnectionStringHelper.ExtractValues(client.ToString(), out _); + if (str.StartsWith("/")) + { + str = "unix:" + str; + } + + Dictionary dictionary = new Dictionary(); + connectionString = null; + if (!Uri.TryCreate(str, UriKind.Absolute, out Uri result)) + { + return false; + } + + if (!new string[4] { "unix", "tcp", "http", "https" }.Contains(result.Scheme)) + { + return false; + } + + if (result.Scheme == "unix") + { + str = result.AbsoluteUri.Substring("unix:".Length); + while (str.Length >= 1 && str[0] == '/') + { + str = str.Substring(1); + } + + result = new Uri("unix://" + str, UriKind.Absolute); + dictionary.Add("type", "clightning"); + } + + if (result.Scheme == "tcp") + { + dictionary.Add("type", "clightning"); + } + + if (result.Scheme == "http" || result.Scheme == "https") + { + string[] array = result.UserInfo.Split(':'); + if (string.IsNullOrEmpty(result.UserInfo) || array.Length != 2) + { + return false; + } + + dictionary.Add("type", "charge"); + dictionary.Add("username", array[0]); + dictionary.Add("password", array[1]); + if (result.Scheme == "http") + { + dictionary.Add("allowinsecure", "true"); + } + } + else if (!string.IsNullOrEmpty(result.UserInfo)) + { + return false; + } + + dictionary.Add("server", new UriBuilder(result) + { + UserName = "", + Password = "" + }.Uri.ToString()); + connectionString = dictionary; + return true; + } + + static Dictionary ExtractValues(this ILightningClient client, string connectionString) + { + ArgumentNullException.ThrowIfNull(connectionString); + if (TryParseLegacy(connectionString, out var legacy)) + return legacy; + string[] source = connectionString.Split(new char[1] { ';' }, StringSplitOptions.RemoveEmptyEntries); + var kv = new Dictionary(); + foreach (string item in source.Select((string p) => p.Trim())) + { + int num = item.IndexOf('='); + if (num == -1) + continue; + string text = item.Substring(0, num).Trim().ToLowerInvariant(); + string value = item.Substring(num + 1).Trim(); + kv.TryAdd(text, value); + } + return kv; + } + + [Obsolete("Use IsSafe(this ILightningClient client, string connectionString) instead")] + public static bool IsSafe(this ILightningClient client) => IsSafe(client, client.ToString()); + public static bool IsSafe(this ILightningClient client, string connectionString) + { + var kv = client.ExtractValues(connectionString); if (kv.TryGetValue("cookiefilepath", out _) || kv.TryGetValue("macaroondirectorypath", out _) || kv.TryGetValue("macaroonfilepath", out _) ) @@ -662,6 +757,9 @@ namespace BTCPayServer return controller.View("PostRedirect", redirectVm); } + public static string RemoveUserInfo(this Uri uri) + => string.IsNullOrEmpty(uri.UserInfo) ? uri.ToString() : uri.ToString().Replace(uri.UserInfo, "***"); + public static DataDirectories Configure(this DataDirectories dataDirectories, IConfiguration configuration) { var networkType = DefaultConfiguration.GetNetworkType(configuration); diff --git a/BTCPayServer/Payments/Lightning/IExtendedLightningClient.cs b/BTCPayServer/Payments/Lightning/IExtendedLightningClient.cs new file mode 100644 index 000000000..ec8d4984c --- /dev/null +++ b/BTCPayServer/Payments/Lightning/IExtendedLightningClient.cs @@ -0,0 +1,25 @@ +#nullable enable +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using BTCPayServer.Lightning; + +namespace BTCPayServer.Payments.Lightning +{ + public interface IExtendedLightningClient : ILightningClient + { + /// + /// Used to validate the client configuration + /// + /// + public Task Validate(); + /// + /// The display name of this client (ie. LND (REST), Eclair, LNDhub) + /// + public string? DisplayName { get; } + /// + /// The server URI of this client (ie. http://localhost:8080) + /// + public Uri? ServerUri { get; } + } +} diff --git a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs index 520e0e0b7..e4bc9a86f 100644 --- a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -263,7 +264,16 @@ namespace BTCPayServer.Payments.Lightning try { var client = _lightningClientFactory.Create(config.ConnectionString, _Network); - if (!client.IsSafe()) + if (client is IExtendedLightningClient vlc) + { + var result = await vlc.Validate(); + if (result != ValidationResult.Success) + { + validationContext.ModelState.AddModelError(nameof(config.ConnectionString), result?.ErrorMessage ?? "Invalid connection string"); + return; + } + } + if (!client.IsSafe(config.ConnectionString)) { var canManage = (await validationContext.AuthorizationService.AuthorizeAsync(validationContext.User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded; @@ -274,6 +284,11 @@ namespace BTCPayServer.Payments.Lightning } } } + catch (FormatException ex) + { + validationContext.ModelState.AddModelError(nameof(config.ConnectionString), ex.Message); + return; + } catch { validationContext.ModelState.AddModelError(nameof(config.ConnectionString), "Invalid connection string"); diff --git a/BTCPayServer/Payments/Lightning/LightningListener.cs b/BTCPayServer/Payments/Lightning/LightningListener.cs index 759e42bfd..e05e9a012 100644 --- a/BTCPayServer/Payments/Lightning/LightningListener.cs +++ b/BTCPayServer/Payments/Lightning/LightningListener.cs @@ -500,27 +500,20 @@ retry: public CancellationTokenSource? StopListeningCancellationTokenSource; async Task Listen(CancellationToken cancellation) { - Uri? uri = null; - string? logUrl = null; + string? uri = null; try { var lightningClient = _lightningClientFactory.Create(ConnectionString, _network); if(lightningClient is null) return; - uri = lightningClient.GetServerUri(); - logUrl = uri switch - { - null when LightningConnectionStringHelper.ExtractValues(ConnectionString, out var type) is not null => type, - null => string.Empty, - _ => string.IsNullOrEmpty(uri.UserInfo) ? uri.ToString() : uri.ToString().Replace(uri.UserInfo, "***") - }; - Logs.PayServer.LogInformation("{CryptoCode} (Lightning): Start listening {Uri}", _network.CryptoCode, logUrl); + uri = lightningClient.GetServerUri(ConnectionString)?.RemoveUserInfo() ?? ""; + Logs.PayServer.LogInformation("{CryptoCode} (Lightning): Start listening {Uri}", _network.CryptoCode, uri); using var session = await lightningClient.Listen(cancellation); // Just in case the payment arrived after our last poll but before we listened. await PollAllListenedInvoices(cancellation); if (_ErrorAlreadyLogged) { - Logs.PayServer.LogInformation("{CryptoCode} (Lightning): Could reconnect successfully to {Uri}", _network.CryptoCode, logUrl); + Logs.PayServer.LogInformation("{CryptoCode} (Lightning): Could reconnect successfully to {Uri}", _network.CryptoCode, uri); } _ErrorAlreadyLogged = false; while (!_ListenedInvoices.IsEmpty) @@ -552,12 +545,12 @@ retry: catch (Exception ex) when (!cancellation.IsCancellationRequested && !_ErrorAlreadyLogged) { _ErrorAlreadyLogged = true; - Logs.PayServer.LogError(ex, "{CryptoCode} (Lightning): Error while contacting {Uri}", _network.CryptoCode, logUrl); - Logs.PayServer.LogInformation("{CryptoCode} (Lightning): Stop listening {Uri}", _network.CryptoCode, logUrl); + Logs.PayServer.LogError(ex, "{CryptoCode} (Lightning): Error while contacting {Uri}", _network.CryptoCode, uri); + Logs.PayServer.LogInformation("{CryptoCode} (Lightning): Stop listening {Uri}", _network.CryptoCode, uri); } catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { } if (_ListenedInvoices.IsEmpty) - Logs.PayServer.LogInformation("{CryptoCode} (Lightning): No more invoice to listen on {Uri}, releasing the connection", _network.CryptoCode, logUrl); + Logs.PayServer.LogInformation("{CryptoCode} (Lightning): No more invoice to listen on {Uri}, releasing the connection", _network.CryptoCode, uri); } private uint256? GetPaymentHash(ListenedInvoice listenedInvoice) diff --git a/BTCPayServer/Views/UIStores/Lightning.cshtml b/BTCPayServer/Views/UIStores/Lightning.cshtml index dceab930f..272d3f079 100644 --- a/BTCPayServer/Views/UIStores/Lightning.cshtml +++ b/BTCPayServer/Views/UIStores/Lightning.cshtml @@ -19,8 +19,8 @@ @try { var client = LightningClientFactoryService.Create(Model.ConnectionString, NetworkProvider.GetNetwork(Model.CryptoCode)); - @client.GetDisplayName() - var uri = client.GetServerUri(); + @client.GetDisplayName(Model.ConnectionString) + var uri = client.GetServerUri(Model.ConnectionString); if (uri is not null) { (@uri.Host) diff --git a/BTCPayServer/Views/UIStores/LightningSettings.cshtml b/BTCPayServer/Views/UIStores/LightningSettings.cshtml index c787d0b00..c4c87444c 100644 --- a/BTCPayServer/Views/UIStores/LightningSettings.cshtml +++ b/BTCPayServer/Views/UIStores/LightningSettings.cshtml @@ -23,8 +23,8 @@ @try { var client = LightningClientFactoryService.Create(Model.ConnectionString, NetworkProvider.GetNetwork(Model.CryptoCode)); - @client.GetDisplayName() - var uri = client.GetServerUri(); + @client.GetDisplayName(Model.ConnectionString) + var uri = client.GetServerUri(Model.ConnectionString); if (uri is not null) { (@uri.Host)