mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-22 22:25:28 +01:00
Do not expose the config secret in URL, and use {net.CryptoCode}.external.lnd.grpc argument
This commit is contained in:
parent
624252e4ad
commit
11a26c940d
7 changed files with 107 additions and 79 deletions
|
@ -74,23 +74,40 @@ namespace BTCPayServer.Configuration
|
||||||
setting.ExplorerUri = conf.GetOrDefault<Uri>($"{net.CryptoCode}.explorer.url", net.NBXplorerNetwork.DefaultSettings.DefaultUrl);
|
setting.ExplorerUri = conf.GetOrDefault<Uri>($"{net.CryptoCode}.explorer.url", net.NBXplorerNetwork.DefaultSettings.DefaultUrl);
|
||||||
setting.CookieFile = conf.GetOrDefault<string>($"{net.CryptoCode}.explorer.cookiefile", net.NBXplorerNetwork.DefaultSettings.DefaultCookieFile);
|
setting.CookieFile = conf.GetOrDefault<string>($"{net.CryptoCode}.explorer.cookiefile", net.NBXplorerNetwork.DefaultSettings.DefaultCookieFile);
|
||||||
NBXplorerConnectionSettings.Add(setting);
|
NBXplorerConnectionSettings.Add(setting);
|
||||||
var lightning = conf.GetOrDefault<string>($"{net.CryptoCode}.lightning", string.Empty);
|
|
||||||
if(lightning.Length != 0)
|
|
||||||
{
|
{
|
||||||
if(!LightningConnectionString.TryParse(lightning, true, out var connectionString, out var error))
|
var lightning = conf.GetOrDefault<string>($"{net.CryptoCode}.lightning", string.Empty);
|
||||||
|
if (lightning.Length != 0)
|
||||||
{
|
{
|
||||||
throw new ConfigException($"Invalid setting {net.CryptoCode}.lightning, " + Environment.NewLine +
|
if (!LightningConnectionString.TryParse(lightning, true, out var connectionString, out var error))
|
||||||
$"If you have a lightning server use: 'type=clightning;server=/root/.lightning/lightning-rpc', " + Environment.NewLine +
|
{
|
||||||
$"If you have a lightning charge server: 'type=charge;server=https://charge.example.com;api-token=yourapitoken'" + Environment.NewLine +
|
throw new ConfigException($"Invalid setting {net.CryptoCode}.lightning, " + Environment.NewLine +
|
||||||
$"If you have a lnd server: 'type=lnd-rest;server=https://lnd:lnd@lnd.example.com;macaroon=abf239...;certthumbprint=2abdf302...'" + Environment.NewLine +
|
$"If you have a lightning server use: 'type=clightning;server=/root/.lightning/lightning-rpc', " + Environment.NewLine +
|
||||||
$" lnd server: 'type=lnd-rest;server=https://lnd:lnd@lnd.example.com;macaroonfilepath=/root/.lnd/admin.macaroon;certthumbprint=2abdf302...'" + Environment.NewLine +
|
$"If you have a lightning charge server: 'type=charge;server=https://charge.example.com;api-token=yourapitoken'" + Environment.NewLine +
|
||||||
error);
|
$"If you have a lnd server: 'type=lnd-rest;server=https://lnd:lnd@lnd.example.com;macaroon=abf239...;certthumbprint=2abdf302...'" + Environment.NewLine +
|
||||||
|
$" lnd server: 'type=lnd-rest;server=https://lnd:lnd@lnd.example.com;macaroonfilepath=/root/.lnd/admin.macaroon;certthumbprint=2abdf302...'" + Environment.NewLine +
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
if (connectionString.IsLegacy)
|
||||||
|
{
|
||||||
|
Logs.Configuration.LogWarning($"Setting {net.CryptoCode}.lightning will work but use an deprecated format, please replace it by '{connectionString.ToString()}'");
|
||||||
|
}
|
||||||
|
InternalLightningByCryptoCode.Add(net.CryptoCode, connectionString);
|
||||||
}
|
}
|
||||||
if(connectionString.IsLegacy)
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
var lightning = conf.GetOrDefault<string>($"{net.CryptoCode}.external.lnd.grpc", string.Empty);
|
||||||
|
if (lightning.Length != 0)
|
||||||
{
|
{
|
||||||
Logs.Configuration.LogWarning($"Setting {net.CryptoCode}.lightning will work but use an deprecated format, please replace it by '{connectionString.ToString()}'");
|
if (!LightningConnectionString.TryParse(lightning, false, out var connectionString, out var error))
|
||||||
|
{
|
||||||
|
throw new ConfigException($"Invalid setting {net.CryptoCode}.external.lnd.grpc, " + Environment.NewLine +
|
||||||
|
$"lnd server: 'type=lnd-grpc;server=https://lnd.example.com;macaroon=abf239...;certthumbprint=2abdf302...'" + Environment.NewLine +
|
||||||
|
$"lnd server: 'type=lnd-grpc;server=https://lnd.example.com;macaroonfilepath=/root/.lnd/admin.macaroon;certthumbprint=2abdf302...'" + Environment.NewLine +
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
ExternalServicesByCryptoCode.Add(net.CryptoCode, new ExternalLNDGRPC(connectionString));
|
||||||
}
|
}
|
||||||
InternalLightningByCryptoCode.Add(net.CryptoCode, connectionString);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,11 +121,12 @@ namespace BTCPayServer.Configuration
|
||||||
if (!RootPath.StartsWith("/", StringComparison.InvariantCultureIgnoreCase))
|
if (!RootPath.StartsWith("/", StringComparison.InvariantCultureIgnoreCase))
|
||||||
RootPath = "/" + RootPath;
|
RootPath = "/" + RootPath;
|
||||||
var old = conf.GetOrDefault<Uri>("internallightningnode", null);
|
var old = conf.GetOrDefault<Uri>("internallightningnode", null);
|
||||||
if(old != null)
|
if (old != null)
|
||||||
throw new ConfigException($"internallightningnode should not be used anymore, use btclightning instead");
|
throw new ConfigException($"internallightningnode should not be used anymore, use btclightning instead");
|
||||||
}
|
}
|
||||||
public string RootPath { get; set; }
|
public string RootPath { get; set; }
|
||||||
public Dictionary<string, LightningConnectionString> InternalLightningByCryptoCode { get; set; } = new Dictionary<string, LightningConnectionString>();
|
public Dictionary<string, LightningConnectionString> InternalLightningByCryptoCode { get; set; } = new Dictionary<string, LightningConnectionString>();
|
||||||
|
public ExternalServices ExternalServicesByCryptoCode { get; set; } = new ExternalServices();
|
||||||
|
|
||||||
public BTCPayNetworkProvider NetworkProvider { get; set; }
|
public BTCPayNetworkProvider NetworkProvider { get; set; }
|
||||||
public string PostgresConnectionString
|
public string PostgresConnectionString
|
||||||
|
@ -136,4 +154,29 @@ namespace BTCPayServer.Configuration
|
||||||
return builder.ToString();
|
return builder.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ExternalServices : MultiValueDictionary<string, ExternalService>
|
||||||
|
{
|
||||||
|
public IEnumerable<T> GetServices<T>(string cryptoCode) where T : ExternalService
|
||||||
|
{
|
||||||
|
if (!this.TryGetValue(cryptoCode.ToUpperInvariant(), out var services))
|
||||||
|
return Array.Empty<T>();
|
||||||
|
return services.OfType<T>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExternalService
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExternalLNDGRPC : ExternalService
|
||||||
|
{
|
||||||
|
public ExternalLNDGRPC(LightningConnectionString connectionString)
|
||||||
|
{
|
||||||
|
ConnectionString = connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LightningConnectionString ConnectionString { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,6 +40,7 @@ namespace BTCPayServer.Configuration
|
||||||
app.Option($"--{crypto}explorerurl", $"URL of the NBXplorer for {network.CryptoCode} (default: {network.NBXplorerNetwork.DefaultSettings.DefaultUrl})", CommandOptionType.SingleValue);
|
app.Option($"--{crypto}explorerurl", $"URL of the NBXplorer for {network.CryptoCode} (default: {network.NBXplorerNetwork.DefaultSettings.DefaultUrl})", CommandOptionType.SingleValue);
|
||||||
app.Option($"--{crypto}explorercookiefile", $"Path to the cookie file (default: {network.NBXplorerNetwork.DefaultSettings.DefaultCookieFile})", CommandOptionType.SingleValue);
|
app.Option($"--{crypto}explorercookiefile", $"Path to the cookie file (default: {network.NBXplorerNetwork.DefaultSettings.DefaultCookieFile})", CommandOptionType.SingleValue);
|
||||||
app.Option($"--{crypto}lightning", $"Easy configuration of lightning for the server administrator: Must be a UNIX socket of c-lightning (lightning-rpc) or URL to a charge server (default: empty)", CommandOptionType.SingleValue);
|
app.Option($"--{crypto}lightning", $"Easy configuration of lightning for the server administrator: Must be a UNIX socket of c-lightning (lightning-rpc) or URL to a charge server (default: empty)", CommandOptionType.SingleValue);
|
||||||
|
app.Option($"--{crypto}externallndgrpc", $"The LND gRPC configuration BTCPay will expose to easily connect to the internal lnd wallet from Zap wallet (default: empty)", CommandOptionType.SingleValue);
|
||||||
}
|
}
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ using BTCPayServer.Validations;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using NBitcoin;
|
||||||
using NBitcoin.DataEncoders;
|
using NBitcoin.DataEncoders;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
@ -238,65 +239,31 @@ namespace BTCPayServer.Controllers
|
||||||
public IActionResult Services()
|
public IActionResult Services()
|
||||||
{
|
{
|
||||||
var result = new ServicesViewModel();
|
var result = new ServicesViewModel();
|
||||||
foreach (var internalNode in _Options.InternalLightningByCryptoCode)
|
foreach (var cryptoCode in _Options.ExternalServicesByCryptoCode.Keys)
|
||||||
{
|
{
|
||||||
//Only BTC can be supported because gRPC does not allow http path rewriting.
|
|
||||||
if (internalNode.Key == "BTC" && GetExternalLNDConnectionString(internalNode.Value) != null)
|
|
||||||
{
|
{
|
||||||
result.LNDServices.Add(new ServicesViewModel.LNDServiceViewModel()
|
int i = 0;
|
||||||
|
foreach (var grpcService in _Options.ExternalServicesByCryptoCode.GetServices<ExternalLNDGRPC>(cryptoCode))
|
||||||
{
|
{
|
||||||
Crypto = internalNode.Key,
|
result.LNDServices.Add(new ServicesViewModel.LNDServiceViewModel()
|
||||||
Type = "gRPC"
|
{
|
||||||
});
|
Crypto = cryptoCode,
|
||||||
|
Type = "gRPC",
|
||||||
|
Index = i++,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return View(result);
|
return View(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private LightningConnectionString GetExternalLNDConnectionString(LightningConnectionString value)
|
[Route("server/services/lnd-grpc/{cryptoCode}/{index}")]
|
||||||
|
public IActionResult LNDGRPCServices(string cryptoCode, int index, uint? nonce)
|
||||||
{
|
{
|
||||||
if (value.ConnectionType != LightningConnectionType.LndREST)
|
var external = GetExternalLNDConnectionString(cryptoCode, index);
|
||||||
return null;
|
if (external == null)
|
||||||
var external = new LightningConnectionString();
|
|
||||||
external.ConnectionType = LightningConnectionType.LndREST;
|
|
||||||
external.BaseUri = _Options.ExternalUrl ?? value.BaseUri;
|
|
||||||
if (external.BaseUri.Scheme == "http" || value.AllowInsecure)
|
|
||||||
{
|
|
||||||
external.AllowInsecure = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (value.MacaroonFilePath != null)
|
|
||||||
external.Macaroon = System.IO.File.ReadAllBytes(value.MacaroonFilePath);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (value.Macaroon != null)
|
|
||||||
external.Macaroon = value.Macaroon;
|
|
||||||
|
|
||||||
// If external url is provided, then we don't care about cert thumbprint
|
|
||||||
// because we override it at the reverse proxy level with a trusted certificate
|
|
||||||
if (_Options.ExternalUrl == null)
|
|
||||||
{
|
|
||||||
if (value.CertificateThumbprint != null)
|
|
||||||
{
|
|
||||||
external.CertificateThumbprint = value.CertificateThumbprint;
|
|
||||||
external.AllowInsecure = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return external;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Route("server/services/lnd-grpc/{cryptoCode}")]
|
|
||||||
public IActionResult LNDGRPCServices(string cryptoCode, ulong? secret)
|
|
||||||
{
|
|
||||||
if (!_Options.InternalLightningByCryptoCode.TryGetValue(cryptoCode.ToUpperInvariant(), out var connectionString))
|
|
||||||
return NotFound();
|
return NotFound();
|
||||||
var model = new LNDGRPCServicesViewModel();
|
var model = new LNDGRPCServicesViewModel();
|
||||||
var external = GetExternalLNDConnectionString(connectionString);
|
|
||||||
|
|
||||||
model.Host = $"{external.BaseUri.DnsSafeHost}:{external.BaseUri.Port}";
|
model.Host = $"{external.BaseUri.DnsSafeHost}:{external.BaseUri.Port}";
|
||||||
model.SSL = external.BaseUri.Scheme == "https";
|
model.SSL = external.BaseUri.Scheme == "https";
|
||||||
|
@ -308,34 +275,43 @@ namespace BTCPayServer.Controllers
|
||||||
{
|
{
|
||||||
model.Macaroon = Encoders.Hex.EncodeData(external.Macaroon);
|
model.Macaroon = Encoders.Hex.EncodeData(external.Macaroon);
|
||||||
}
|
}
|
||||||
if (secret != null)
|
|
||||||
|
if (nonce != null)
|
||||||
{
|
{
|
||||||
var lnConfig = _LnConfigProvider.GetConfig(secret.Value);
|
var configKey = GetConfigKey("lnd-grpc", cryptoCode, index, nonce.Value);
|
||||||
|
var lnConfig = _LnConfigProvider.GetConfig(configKey);
|
||||||
if (lnConfig != null)
|
if (lnConfig != null)
|
||||||
{
|
{
|
||||||
model.QRCodeLink = $"{this.Request.GetAbsoluteRoot().WithTrailingSlash()}lnd-config/{secret.Value}/lnd.config";
|
model.QRCodeLink = $"{this.Request.GetAbsoluteRoot().WithTrailingSlash()}lnd-config/{configKey}/lnd.config";
|
||||||
model.QRCode = $"config={model.QRCodeLink}";
|
model.QRCode = $"config={model.QRCodeLink}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
[Route("lnd-config/{secret}/lnd.config")]
|
|
||||||
[AllowAnonymous]
|
private static uint GetConfigKey(string type, string cryptoCode, int index, uint nonce)
|
||||||
public IActionResult GetLNDConfig(ulong secret)
|
|
||||||
{
|
{
|
||||||
var conf = _LnConfigProvider.GetConfig(secret);
|
return (uint)HashCode.Combine(type, cryptoCode, index, nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route("lnd-config/{configKey}/lnd.config")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public IActionResult GetLNDConfig(uint configKey)
|
||||||
|
{
|
||||||
|
var conf = _LnConfigProvider.GetConfig(configKey);
|
||||||
if (conf == null)
|
if (conf == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
return Json(conf);
|
return Json(conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Route("server/services/lnd-grpc/{cryptoCode}")]
|
[Route("server/services/lnd-grpc/{cryptoCode}/{index}")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public IActionResult LNDGRPCServicesPOST(string cryptoCode)
|
public IActionResult LNDGRPCServicesPOST(string cryptoCode, int index)
|
||||||
{
|
{
|
||||||
if (!_Options.InternalLightningByCryptoCode.TryGetValue(cryptoCode.ToUpperInvariant(), out var connectionString))
|
var external = GetExternalLNDConnectionString(cryptoCode, index);
|
||||||
|
if (external == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
var external = GetExternalLNDConnectionString(connectionString);
|
|
||||||
LightningConfigurations confs = new LightningConfigurations();
|
LightningConfigurations confs = new LightningConfigurations();
|
||||||
LightningConfiguration conf = new LightningConfiguration();
|
LightningConfiguration conf = new LightningConfiguration();
|
||||||
conf.Type = "grpc";
|
conf.Type = "grpc";
|
||||||
|
@ -346,8 +322,16 @@ namespace BTCPayServer.Controllers
|
||||||
conf.Macaroon = external.Macaroon == null ? null : Encoders.Hex.EncodeData(external.Macaroon);
|
conf.Macaroon = external.Macaroon == null ? null : Encoders.Hex.EncodeData(external.Macaroon);
|
||||||
conf.CertificateThumbprint = external.CertificateThumbprint == null ? null : Encoders.Hex.EncodeData(external.CertificateThumbprint);
|
conf.CertificateThumbprint = external.CertificateThumbprint == null ? null : Encoders.Hex.EncodeData(external.CertificateThumbprint);
|
||||||
confs.Configurations.Add(conf);
|
confs.Configurations.Add(conf);
|
||||||
var secret = _LnConfigProvider.KeepConfig(confs);
|
|
||||||
return RedirectToAction(nameof(LNDGRPCServices), new { cryptoCode = cryptoCode, secret = secret });
|
var nonce = RandomUtils.GetUInt32();
|
||||||
|
var configKey = GetConfigKey("lnd-grpc", cryptoCode, index, nonce);
|
||||||
|
_LnConfigProvider.KeepConfig(configKey, confs);
|
||||||
|
return RedirectToAction(nameof(LNDGRPCServices), new { cryptoCode = cryptoCode, nonce = nonce });
|
||||||
|
}
|
||||||
|
|
||||||
|
private LightningConnectionString GetExternalLNDConnectionString(string cryptoCode, int index)
|
||||||
|
{
|
||||||
|
return _Options.ExternalServicesByCryptoCode.GetServices<ExternalLNDGRPC>(cryptoCode).Skip(index).Select(c => c.ConnectionString).FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Route("server/theme")]
|
[Route("server/theme")]
|
||||||
|
|
|
@ -11,6 +11,7 @@ namespace BTCPayServer.Models.ServerViewModels
|
||||||
{
|
{
|
||||||
public string Crypto { get; set; }
|
public string Crypto { get; set; }
|
||||||
public string Type { get; set; }
|
public string Type { get; set; }
|
||||||
|
public int Index { get; set; }
|
||||||
}
|
}
|
||||||
public List<LNDServiceViewModel> LNDServices { get; set; } = new List<LNDServiceViewModel>();
|
public List<LNDServiceViewModel> LNDServices { get; set; } = new List<LNDServiceViewModel>();
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,8 @@
|
||||||
"BTCPAY_BTCEXPLORERURL": "http://127.0.0.1:32838/",
|
"BTCPAY_BTCEXPLORERURL": "http://127.0.0.1:32838/",
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
"BTCPAY_CHAINS": "btc,ltc",
|
"BTCPAY_CHAINS": "btc,ltc",
|
||||||
"BTCPAY_BTCLIGHTNING": "http://api-token:foiewnccewuify@127.0.0.1:54938/",
|
"BTCPAY_BTCLIGHTNING": "type=charge;server=http://127.0.0.1:54938/;api-token=foiewnccewuify",
|
||||||
//"BTCPAY_BTCLIGHTNING": "type=lnd-rest;server=https://lnd:lnd@127.0.0.1:53280/;allowinsecure=true",
|
"BTCPAY_BTCEXTERNALLNDGRPC": "type=lnd-rest;server=https://lnd:lnd@127.0.0.1:53280/;allowinsecure=true",
|
||||||
"BTCPAY_POSTGRES": "User ID=postgres;Host=127.0.0.1;Port=39372;Database=btcpayserver",
|
"BTCPAY_POSTGRES": "User ID=postgres;Host=127.0.0.1;Port=39372;Database=btcpayserver",
|
||||||
"BTCPAY_BUNDLEJSCSS": "false"
|
"BTCPAY_BUNDLEJSCSS": "false"
|
||||||
},
|
},
|
||||||
|
|
|
@ -10,10 +10,9 @@ namespace BTCPayServer.Services
|
||||||
public class LightningConfigurationProvider
|
public class LightningConfigurationProvider
|
||||||
{
|
{
|
||||||
ConcurrentDictionary<ulong, (DateTimeOffset expiration, LightningConfigurations config)> _Map = new ConcurrentDictionary<ulong, (DateTimeOffset expiration, LightningConfigurations config)>();
|
ConcurrentDictionary<ulong, (DateTimeOffset expiration, LightningConfigurations config)> _Map = new ConcurrentDictionary<ulong, (DateTimeOffset expiration, LightningConfigurations config)>();
|
||||||
public ulong KeepConfig(LightningConfigurations configuration)
|
public ulong KeepConfig(ulong secret, LightningConfigurations configuration)
|
||||||
{
|
{
|
||||||
CleanExpired();
|
CleanExpired();
|
||||||
var secret = RandomUtils.GetUInt64();
|
|
||||||
_Map.AddOrReplace(secret, (DateTimeOffset.UtcNow + TimeSpan.FromMinutes(10), configuration));
|
_Map.AddOrReplace(secret, (DateTimeOffset.UtcNow + TimeSpan.FromMinutes(10), configuration));
|
||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
<td>@lnd.Crypto</td>
|
<td>@lnd.Crypto</td>
|
||||||
<td>@lnd.Type</td>
|
<td>@lnd.Type</td>
|
||||||
<td style="text-align:right">
|
<td style="text-align:right">
|
||||||
<a asp-action="LNDGRPCServices" asp-route-cryptoCode="@lnd.Crypto">See information</a>
|
<a asp-action="LNDGRPCServices" asp-route-cryptoCode="@lnd.Crypto" asp-route-index="@lnd.Index">See information</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue