Asyncify SSH access, do not show SSH service if ssh is not well configured

This commit is contained in:
nicolas.dorier
2019-08-27 23:30:25 +09:00
parent 9a9e31c759
commit 9688798a4a
11 changed files with 220 additions and 153 deletions

View File

@@ -194,7 +194,7 @@ namespace BTCPayServer.Configuration
{ {
if (!SSHFingerprint.TryParse(fingerprint, out var f)) if (!SSHFingerprint.TryParse(fingerprint, out var f))
throw new ConfigException($"Invalid ssh fingerprint format {fingerprint}"); throw new ConfigException($"Invalid ssh fingerprint format {fingerprint}");
TrustedFingerprints.Add(f); SSHSettings?.TrustedFingerprints.Add(f);
} }
} }
@@ -249,11 +249,6 @@ namespace BTCPayServer.Configuration
return settings; return settings;
} }
internal bool IsTrustedFingerprint(byte[] fingerPrint, byte[] hostKey)
{
return TrustedFingerprints.Any(f => f.Match(fingerPrint, hostKey));
}
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>();
@@ -277,7 +272,6 @@ namespace BTCPayServer.Configuration
set; set;
} }
public bool AllowAdminRegistration { get; set; } public bool AllowAdminRegistration { get; set; }
public List<SSHFingerprint> TrustedFingerprints { get; set; } = new List<SSHFingerprint>();
public SSHSettings SSHSettings public SSHSettings SSHSettings
{ {
get; get;

View File

@@ -49,6 +49,7 @@ namespace BTCPayServer.Controllers
private readonly TorServices _torServices; private readonly TorServices _torServices;
private BTCPayServerOptions _Options; private BTCPayServerOptions _Options;
private readonly AppService _AppService; private readonly AppService _AppService;
private readonly CheckConfigurationHostedService _sshState;
private readonly StoredFileRepository _StoredFileRepository; private readonly StoredFileRepository _StoredFileRepository;
private readonly FileService _FileService; private readonly FileService _FileService;
private readonly IEnumerable<IStorageProviderService> _StorageProviderServices; private readonly IEnumerable<IStorageProviderService> _StorageProviderServices;
@@ -64,7 +65,8 @@ namespace BTCPayServer.Controllers
LightningConfigurationProvider lnConfigProvider, LightningConfigurationProvider lnConfigProvider,
TorServices torServices, TorServices torServices,
StoreRepository storeRepository, StoreRepository storeRepository,
AppService appService) AppService appService,
CheckConfigurationHostedService sshState)
{ {
_Options = options; _Options = options;
_StoredFileRepository = storedFileRepository; _StoredFileRepository = storedFileRepository;
@@ -78,6 +80,7 @@ namespace BTCPayServer.Controllers
_LnConfigProvider = lnConfigProvider; _LnConfigProvider = lnConfigProvider;
_torServices = torServices; _torServices = torServices;
_AppService = appService; _AppService = appService;
_sshState = sshState;
} }
[Route("server/rates")] [Route("server/rates")]
@@ -186,9 +189,8 @@ namespace BTCPayServer.Controllers
public IActionResult Maintenance() public IActionResult Maintenance()
{ {
MaintenanceViewModel vm = new MaintenanceViewModel(); MaintenanceViewModel vm = new MaintenanceViewModel();
vm.UserName = "btcpayserver"; vm.CanUseSSH = _sshState.CanUseSSH;
vm.DNSDomain = this.Request.Host.Host; vm.DNSDomain = this.Request.Host.Host;
vm.SetConfiguredSSH(_Options.SSHSettings);
if (IPAddress.TryParse(vm.DNSDomain, out var unused)) if (IPAddress.TryParse(vm.DNSDomain, out var unused))
vm.DNSDomain = null; vm.DNSDomain = null;
return View(vm); return View(vm);
@@ -198,9 +200,9 @@ namespace BTCPayServer.Controllers
[HttpPost] [HttpPost]
public async Task<IActionResult> Maintenance(MaintenanceViewModel vm, string command) public async Task<IActionResult> Maintenance(MaintenanceViewModel vm, string command)
{ {
vm.CanUseSSH = _sshState.CanUseSSH;
if (!ModelState.IsValid) if (!ModelState.IsValid)
return View(vm); return View(vm);
vm.SetConfiguredSSH(_Options.SSHSettings);
if (command == "changedomain") if (command == "changedomain")
{ {
if (string.IsNullOrWhiteSpace(vm.DNSDomain)) if (string.IsNullOrWhiteSpace(vm.DNSDomain))
@@ -254,7 +256,7 @@ namespace BTCPayServer.Controllers
} }
} }
var error = RunSSH(vm, $"changedomain.sh {vm.DNSDomain}"); var error = await RunSSH(vm, $"changedomain.sh {vm.DNSDomain}");
if (error != null) if (error != null)
return error; return error;
@@ -264,14 +266,14 @@ namespace BTCPayServer.Controllers
} }
else if (command == "update") else if (command == "update")
{ {
var error = RunSSH(vm, $"btcpay-update.sh"); var error = await RunSSH(vm, $"btcpay-update.sh");
if (error != null) if (error != null)
return error; return error;
StatusMessage = $"The server might restart soon if an update is available..."; StatusMessage = $"The server might restart soon if an update is available...";
} }
else if (command == "clean") else if (command == "clean")
{ {
var error = RunSSH(vm, $"btcpay-clean.sh"); var error = await RunSSH(vm, $"btcpay-clean.sh");
if (error != null) if (error != null)
return error; return error;
StatusMessage = $"The old docker images will be cleaned soon..."; StatusMessage = $"The old docker images will be cleaned soon...";
@@ -301,43 +303,13 @@ namespace BTCPayServer.Controllers
return BadRequest(); return BadRequest();
} }
private IActionResult RunSSH(MaintenanceViewModel vm, string ssh) private async Task<IActionResult> RunSSH(MaintenanceViewModel vm, string command)
{ {
ssh = $"sudo bash -c '. /etc/profile.d/btcpay-env.sh && nohup {ssh} > /dev/null 2>&1 & disown'"; SshClient sshClient = null;
var sshClient = _Options.SSHSettings == null ? vm.CreateSSHClient(this.Request.Host.Host)
: new SshClient(_Options.SSHSettings.CreateConnectionInfo());
if (_Options.TrustedFingerprints.Count != 0)
{
sshClient.HostKeyReceived += (object sender, Renci.SshNet.Common.HostKeyEventArgs e) =>
{
if (_Options.TrustedFingerprints.Count == 0)
{
Logs.Configuration.LogWarning($"SSH host fingerprint for {e.HostKeyName} is untrusted, start BTCPay with -sshtrustedfingerprints \"{Encoders.Hex.EncodeData(e.FingerPrint)}\"");
e.CanTrust = true; // Not a typo, we want the connection to succeed with a warning
}
else
{
e.CanTrust = _Options.IsTrustedFingerprint(e.FingerPrint, e.HostKey);
if (!e.CanTrust)
Logs.Configuration.LogError($"SSH host fingerprint for {e.HostKeyName} is untrusted, start BTCPay with -sshtrustedfingerprints \"{Encoders.Hex.EncodeData(e.FingerPrint)}\"");
}
};
}
else
{
}
try try
{ {
sshClient.Connect(); sshClient = await _Options.SSHSettings.ConnectAsync();
}
catch (Renci.SshNet.Common.SshAuthenticationException)
{
ModelState.AddModelError(nameof(vm.Password), "Invalid credentials");
sshClient.Dispose();
return View(vm);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -346,30 +318,31 @@ namespace BTCPayServer.Controllers
{ {
message = aggrEx.InnerException.Message; message = aggrEx.InnerException.Message;
} }
ModelState.AddModelError(nameof(vm.UserName), $"Connection problem ({message})"); ModelState.AddModelError(string.Empty, $"Connection problem ({message})");
sshClient.Dispose();
return View(vm); return View(vm);
} }
_ = RunSSHCore(sshClient, $". /etc/profile.d/btcpay-env.sh && nohup {command} > /dev/null 2>&1 & disown");
var sshCommand = sshClient.CreateCommand(ssh);
sshCommand.CommandTimeout = TimeSpan.FromMinutes(1.0);
sshCommand.BeginExecute(ar =>
{
try
{
Logs.PayServer.LogInformation("Running SSH command: " + ssh);
var result = sshCommand.EndExecute(ar);
Logs.PayServer.LogInformation("SSH command executed: " + result);
}
catch (Exception ex)
{
Logs.PayServer.LogWarning("Error while executing SSH command: " + ex.Message);
}
sshClient.Dispose();
});
return null; return null;
} }
private static async Task RunSSHCore(SshClient sshClient, string ssh)
{
try
{
Logs.PayServer.LogInformation("Running SSH command: " + ssh);
var result = await sshClient.RunBash(ssh, TimeSpan.FromMinutes(1.0));
Logs.PayServer.LogInformation($"SSH command executed with exit status {result.ExitStatus}. Output: {result.Output}");
}
catch (Exception ex)
{
Logs.PayServer.LogWarning("Error while executing SSH command: " + ex.Message);
}
finally
{
sshClient.Dispose();
}
}
private static bool IsAdmin(IList<string> roles) private static bool IsAdmin(IList<string> roles)
{ {
return roles.Contains(Roles.ServerAdmin, StringComparer.Ordinal); return roles.Contains(Roles.ServerAdmin, StringComparer.Ordinal);
@@ -531,7 +504,7 @@ namespace BTCPayServer.Controllers
Link = this.Request.GetAbsoluteUriNoPathBase(externalService.Value).AbsoluteUri Link = this.Request.GetAbsoluteUriNoPathBase(externalService.Value).AbsoluteUri
}); });
} }
if (_Options.SSHSettings != null) if (_sshState.CanUseSSH)
{ {
result.OtherExternalServices.Add(new ServicesViewModel.OtherExternalService() result.OtherExternalServices.Add(new ServicesViewModel.OtherExternalService()
{ {

View File

@@ -3,5 +3,9 @@ namespace BTCPayServer.Events
public class SettingsChanged<T> public class SettingsChanged<T>
{ {
public T Settings { get; set; } public T Settings { get; set; }
public override string ToString()
{
return Settings?.ToString() ?? string.Empty;
}
} }
} }

View File

@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.SSH;
using Renci.SshNet;
namespace BTCPayServer
{
public static class SSHClientExtensions
{
public static Task<SshClient> ConnectAsync(this SSHSettings sshSettings)
{
if (sshSettings == null)
throw new ArgumentNullException(nameof(sshSettings));
TaskCompletionSource<SshClient> tcs = new TaskCompletionSource<SshClient>(TaskCreationOptions.RunContinuationsAsynchronously);
new Thread(() =>
{
var sshClient = new SshClient(sshSettings.CreateConnectionInfo());
sshClient.HostKeyReceived += (object sender, Renci.SshNet.Common.HostKeyEventArgs e) =>
{
if (sshSettings.TrustedFingerprints.Count == 0)
{
e.CanTrust = true;
}
else
{
e.CanTrust = sshSettings.IsTrustedFingerprint(e.FingerPrint, e.HostKey);
}
};
try
{
sshClient.Connect();
tcs.TrySetResult(sshClient);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
try
{
sshClient.Dispose();
}
catch { }
}
})
{ IsBackground = true }.Start();
return tcs.Task;
}
public static Task<SSHCommandResult> RunBash(this SshClient sshClient, string command, TimeSpan? timeout = null)
{
if (sshClient == null)
throw new ArgumentNullException(nameof(sshClient));
if (command == null)
throw new ArgumentNullException(nameof(command));
command = $"sudo bash -c '{command}'";
var sshCommand = sshClient.CreateCommand(command);
if (timeout is TimeSpan v)
sshCommand.CommandTimeout = v;
var tcs = new TaskCompletionSource<SSHCommandResult>(TaskCreationOptions.RunContinuationsAsynchronously);
new Thread(() =>
{
sshCommand.BeginExecute(ar =>
{
try
{
sshCommand.EndExecute(ar);
tcs.TrySetResult(CreateSSHCommandResult(sshCommand));
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
finally
{
sshCommand.Dispose();
}
});
})
{ IsBackground = true }.Start();
return tcs.Task;
}
private static SSHCommandResult CreateSSHCommandResult(SshCommand sshCommand)
{
return new SSHCommandResult()
{
Output = sshCommand.Result,
Error = sshCommand.Error,
ExitStatus = sshCommand.ExitStatus
};
}
public static Task DisconnectAsync(this SshClient sshClient)
{
if (sshClient == null)
throw new ArgumentNullException(nameof(sshClient));
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
new Thread(() =>
{
try
{
sshClient.Disconnect();
tcs.TrySetResult(true);
}
catch
{
tcs.TrySetResult(true); // We don't care about exception
}
})
{ IsBackground = true }.Start();
return tcs.Task;
}
}
}

View File

@@ -23,49 +23,44 @@ namespace BTCPayServer.HostedServices
_options = options; _options = options;
} }
public bool CanUseSSH { get; private set; }
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
new Thread(() => _ = TestConnection();
return Task.CompletedTask;
}
async Task TestConnection()
{
var canUseSSH = false;
if (_options.SSHSettings != null)
{ {
if (_options.SSHSettings != null) Logs.Configuration.LogInformation($"SSH settings detected, testing connection to {_options.SSHSettings.Username}@{_options.SSHSettings.Server} on port {_options.SSHSettings.Port} ...");
try
{ {
Logs.Configuration.LogInformation($"SSH settings detected, testing connection to {_options.SSHSettings.Username}@{_options.SSHSettings.Server} on port {_options.SSHSettings.Port} ..."); using (var connection = await _options.SSHSettings.ConnectAsync())
var connection = new Renci.SshNet.SshClient(_options.SSHSettings.CreateConnectionInfo());
connection.HostKeyReceived += (object sender, Renci.SshNet.Common.HostKeyEventArgs e) =>
{ {
e.CanTrust = true; await connection.DisconnectAsync();
if (!_options.IsTrustedFingerprint(e.FingerPrint, e.HostKey))
{
Logs.Configuration.LogWarning($"SSH host fingerprint for {e.HostKeyName} is untrusted, start BTCPay with -sshtrustedfingerprints \"{Encoders.Hex.EncodeData(e.FingerPrint)}\"");
}
};
try
{
connection.Connect();
connection.Disconnect();
Logs.Configuration.LogInformation($"SSH connection succeeded"); Logs.Configuration.LogInformation($"SSH connection succeeded");
} canUseSSH = true;
catch (Renci.SshNet.Common.SshAuthenticationException)
{
Logs.Configuration.LogWarning($"SSH invalid credentials");
}
catch (Exception ex)
{
var message = ex.Message;
if (ex is AggregateException aggrEx && aggrEx.InnerException?.Message != null)
{
message = aggrEx.InnerException.Message;
}
Logs.Configuration.LogWarning($"SSH connection issue: {message}");
}
finally
{
connection.Dispose();
} }
} }
}) catch (Renci.SshNet.Common.SshAuthenticationException)
{ IsBackground = true }.Start(); {
return Task.CompletedTask; Logs.Configuration.LogWarning($"SSH invalid credentials");
}
catch (Exception ex)
{
var message = ex.Message;
if (ex is AggregateException aggrEx && aggrEx.InnerException?.Message != null)
{
message = aggrEx.InnerException.Message;
}
Logs.Configuration.LogWarning($"SSH connection issue of type {ex.GetType().Name}: {message}");
}
}
CanUseSSH = canUseSSH;
} }
public Task StopAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken)

View File

@@ -188,7 +188,8 @@ namespace BTCPayServer.Hosting
}); });
services.AddSingleton<IHostedService, CssThemeManagerHostedService>(); services.AddSingleton<IHostedService, CssThemeManagerHostedService>();
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(); services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<CheckConfigurationHostedService>());
services.AddSingleton<BitcoinLikePaymentHandler>(); services.AddSingleton<BitcoinLikePaymentHandler>();
services.AddSingleton<IPaymentMethodHandler>(provider => provider.GetService<BitcoinLikePaymentHandler>()); services.AddSingleton<IPaymentMethodHandler>(provider => provider.GetService<BitcoinLikePaymentHandler>());

View File

@@ -11,27 +11,8 @@ namespace BTCPayServer.Models.ServerViewModels
{ {
public class MaintenanceViewModel public class MaintenanceViewModel
{ {
public bool ExposedSSH { get; set; }
[Required]
public string UserName { get; set; }
[Required]
[DataType(DataType.Password)]
public string Password { get; set; }
[Display(Name = "Change domain")] [Display(Name = "Change domain")]
public string DNSDomain { get; set; } public string DNSDomain { get; set; }
public SshClient CreateSSHClient(string host) public bool CanUseSSH { get; internal set; }
{
return new SshClient(host, UserName, Password);
}
internal void SetConfiguredSSH(SSHSettings settings)
{
if(settings != null)
{
ExposedSSH = true;
UserName = "unknown";
Password = "unknown";
}
}
} }
} }

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.SSH
{
public class SSHCommandResult
{
public int ExitStatus { get; internal set; }
public string Output { get; internal set; }
public string Error { get; internal set; }
}
}

View File

@@ -15,6 +15,11 @@ namespace BTCPayServer.SSH
public string KeyFilePassword { get; set; } public string KeyFilePassword { get; set; }
public string Username { get; set; } public string Username { get; set; }
public string Password { get; set; } public string Password { get; set; }
public List<SSHFingerprint> TrustedFingerprints { get; set; } = new List<SSHFingerprint>();
internal bool IsTrustedFingerprint(byte[] fingerPrint, byte[] hostKey)
{
return TrustedFingerprints.Any(f => f.Match(fingerPrint, hostKey));
}
public ConnectionInfo CreateConnectionInfo() public ConnectionInfo CreateConnectionInfo()
{ {

View File

@@ -13,5 +13,9 @@ namespace BTCPayServer.Services
public bool ConvertNetworkFeeProperty { get; set; } public bool ConvertNetworkFeeProperty { get; set; }
public bool ConvertCrowdfundOldSettings { get; set; } public bool ConvertCrowdfundOldSettings { get; set; }
public bool ConvertWalletKeyPathRoots { get; set; } public bool ConvertWalletKeyPathRoots { get; set; }
public override string ToString()
{
return string.Empty;
}
} }
} }

View File

@@ -5,36 +5,14 @@
<partial name="_StatusMessage" for="@TempData["TempDataProperty-StatusMessage"]" /> <partial name="_StatusMessage" for="@TempData["TempDataProperty-StatusMessage"]" />
@if (!Model.CanUseSSH)
{
<partial name="_StatusMessage" model="@("Error: Maintenance feature requires access to SSH properly configured in BTCPayServer configuration")" />
}
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-8">
<form method="post"> <form method="post">
@if (!Model.ExposedSSH)
{
<div class="form-group">
<h5>SSH Settings</h5>
<span>For changing any settings, you need to enter your SSH credentials:</span>
</div>
<div class="form-group">
<label asp-for="UserName"></label>
<input asp-for="UserName" class="form-control" />
<span asp-validation-for="UserName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Password"></label>
<input asp-for="Password" class="form-control" />
<span asp-validation-for="Password" class="text-danger"></span>
</div>
}
else
{
<input asp-for="Password" type="hidden" class="form-control" />
<span asp-validation-for="Password" class="text-danger"></span>
<input asp-for="UserName" type="hidden" class="form-control" />
<span asp-validation-for="UserName" class="text-danger"></span>
}
<div class="form-group"> <div class="form-group">
<h5>Change domain name</h5> <h5>Change domain name</h5>
<span>You can change the domain name of your server by following <a href="https://github.com/btcpayserver/btcpayserver-doc/blob/master/ChangeDomain.md">this guide</a></span> <span>You can change the domain name of your server by following <a href="https://github.com/btcpayserver/btcpayserver-doc/blob/master/ChangeDomain.md">this guide</a></span>
@@ -44,7 +22,7 @@
<div class="input-group"> <div class="input-group">
<input asp-for="DNSDomain" class="form-control" /> <input asp-for="DNSDomain" class="form-control" />
<span class="input-group-btn"> <span class="input-group-btn">
<button name="command" type="submit" class="btn btn-primary" value="changedomain" title="Change domain"> <button name="command" type="submit" class="btn btn-primary" value="changedomain" title="Change domain" disabled="@(Model.CanUseSSH ? null : "disabled")">
<span class="fa fa-check"></span> Confirm <span class="fa fa-check"></span> Confirm
</button> </button>
</span> </span>
@@ -58,7 +36,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-group"> <div class="input-group">
<button name="command" type="submit" class="btn btn-primary" value="update">Update</button> <button name="command" type="submit" class="btn btn-primary" value="update" disabled="@(Model.CanUseSSH ? null : "disabled")">Update</button>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -67,7 +45,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="input-group"> <div class="input-group">
<button name="command" type="submit" class="btn btn-primary" value="clean">Clean</button> <button name="command" type="submit" class="btn btn-primary" value="clean" disabled="@(Model.CanUseSSH ? null : "disabled")">Clean</button>
</div> </div>
</div> </div>
</form> </form>