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
No known key found for this signature in database
GPG Key ID: 6618763EF09186FE
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))
throw new ConfigException($"Invalid ssh fingerprint format {fingerprint}");
TrustedFingerprints.Add(f);
SSHSettings?.TrustedFingerprints.Add(f);
}
}
@ -249,11 +249,6 @@ namespace BTCPayServer.Configuration
return settings;
}
internal bool IsTrustedFingerprint(byte[] fingerPrint, byte[] hostKey)
{
return TrustedFingerprints.Any(f => f.Match(fingerPrint, hostKey));
}
public string RootPath { get; set; }
public Dictionary<string, LightningConnectionString> InternalLightningByCryptoCode { get; set; } = new Dictionary<string, LightningConnectionString>();
@ -277,7 +272,6 @@ namespace BTCPayServer.Configuration
set;
}
public bool AllowAdminRegistration { get; set; }
public List<SSHFingerprint> TrustedFingerprints { get; set; } = new List<SSHFingerprint>();
public SSHSettings SSHSettings
{
get;

View File

@ -49,6 +49,7 @@ namespace BTCPayServer.Controllers
private readonly TorServices _torServices;
private BTCPayServerOptions _Options;
private readonly AppService _AppService;
private readonly CheckConfigurationHostedService _sshState;
private readonly StoredFileRepository _StoredFileRepository;
private readonly FileService _FileService;
private readonly IEnumerable<IStorageProviderService> _StorageProviderServices;
@ -64,7 +65,8 @@ namespace BTCPayServer.Controllers
LightningConfigurationProvider lnConfigProvider,
TorServices torServices,
StoreRepository storeRepository,
AppService appService)
AppService appService,
CheckConfigurationHostedService sshState)
{
_Options = options;
_StoredFileRepository = storedFileRepository;
@ -78,6 +80,7 @@ namespace BTCPayServer.Controllers
_LnConfigProvider = lnConfigProvider;
_torServices = torServices;
_AppService = appService;
_sshState = sshState;
}
[Route("server/rates")]
@ -186,9 +189,8 @@ namespace BTCPayServer.Controllers
public IActionResult Maintenance()
{
MaintenanceViewModel vm = new MaintenanceViewModel();
vm.UserName = "btcpayserver";
vm.CanUseSSH = _sshState.CanUseSSH;
vm.DNSDomain = this.Request.Host.Host;
vm.SetConfiguredSSH(_Options.SSHSettings);
if (IPAddress.TryParse(vm.DNSDomain, out var unused))
vm.DNSDomain = null;
return View(vm);
@ -198,9 +200,9 @@ namespace BTCPayServer.Controllers
[HttpPost]
public async Task<IActionResult> Maintenance(MaintenanceViewModel vm, string command)
{
vm.CanUseSSH = _sshState.CanUseSSH;
if (!ModelState.IsValid)
return View(vm);
vm.SetConfiguredSSH(_Options.SSHSettings);
if (command == "changedomain")
{
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)
return error;
@ -264,14 +266,14 @@ namespace BTCPayServer.Controllers
}
else if (command == "update")
{
var error = RunSSH(vm, $"btcpay-update.sh");
var error = await RunSSH(vm, $"btcpay-update.sh");
if (error != null)
return error;
StatusMessage = $"The server might restart soon if an update is available...";
}
else if (command == "clean")
{
var error = RunSSH(vm, $"btcpay-clean.sh");
var error = await RunSSH(vm, $"btcpay-clean.sh");
if (error != null)
return error;
StatusMessage = $"The old docker images will be cleaned soon...";
@ -301,43 +303,13 @@ namespace BTCPayServer.Controllers
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'";
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
{
}
SshClient sshClient = null;
try
{
sshClient.Connect();
}
catch (Renci.SshNet.Common.SshAuthenticationException)
{
ModelState.AddModelError(nameof(vm.Password), "Invalid credentials");
sshClient.Dispose();
return View(vm);
sshClient = await _Options.SSHSettings.ConnectAsync();
}
catch (Exception ex)
{
@ -346,30 +318,31 @@ namespace BTCPayServer.Controllers
{
message = aggrEx.InnerException.Message;
}
ModelState.AddModelError(nameof(vm.UserName), $"Connection problem ({message})");
sshClient.Dispose();
ModelState.AddModelError(string.Empty, $"Connection problem ({message})");
return View(vm);
}
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();
});
_ = RunSSHCore(sshClient, $". /etc/profile.d/btcpay-env.sh && nohup {command} > /dev/null 2>&1 & disown");
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)
{
return roles.Contains(Roles.ServerAdmin, StringComparer.Ordinal);
@ -531,7 +504,7 @@ namespace BTCPayServer.Controllers
Link = this.Request.GetAbsoluteUriNoPathBase(externalService.Value).AbsoluteUri
});
}
if (_Options.SSHSettings != null)
if (_sshState.CanUseSSH)
{
result.OtherExternalServices.Add(new ServicesViewModel.OtherExternalService()
{

View File

@ -3,5 +3,9 @@ namespace BTCPayServer.Events
public class SettingsChanged<T>
{
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;
}
public bool CanUseSSH { get; private set; }
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} ...");
var connection = new Renci.SshNet.SshClient(_options.SSHSettings.CreateConnectionInfo());
connection.HostKeyReceived += (object sender, Renci.SshNet.Common.HostKeyEventArgs e) =>
using (var connection = await _options.SSHSettings.ConnectAsync())
{
e.CanTrust = true;
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();
await connection.DisconnectAsync();
Logs.Configuration.LogInformation($"SSH connection succeeded");
}
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();
canUseSSH = true;
}
}
})
{ IsBackground = true }.Start();
return Task.CompletedTask;
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 of type {ex.GetType().Name}: {message}");
}
}
CanUseSSH = canUseSSH;
}
public Task StopAsync(CancellationToken cancellationToken)

View File

@ -188,7 +188,8 @@ namespace BTCPayServer.Hosting
});
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<IPaymentMethodHandler>(provider => provider.GetService<BitcoinLikePaymentHandler>());

View File

@ -11,27 +11,8 @@ namespace BTCPayServer.Models.ServerViewModels
{
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")]
public string DNSDomain { get; set; }
public SshClient CreateSSHClient(string host)
{
return new SshClient(host, UserName, Password);
}
internal void SetConfiguredSSH(SSHSettings settings)
{
if(settings != null)
{
ExposedSSH = true;
UserName = "unknown";
Password = "unknown";
}
}
public bool CanUseSSH { get; internal set; }
}
}

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 Username { 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()
{

View File

@ -13,5 +13,9 @@ namespace BTCPayServer.Services
public bool ConvertNetworkFeeProperty { get; set; }
public bool ConvertCrowdfundOldSettings { 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"]" />
@if (!Model.CanUseSSH)
{
<partial name="_StatusMessage" model="@("Error: Maintenance feature requires access to SSH properly configured in BTCPayServer configuration")" />
}
<div class="row">
<div class="col-md-8">
<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">
<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>
@ -44,7 +22,7 @@
<div class="input-group">
<input asp-for="DNSDomain" class="form-control" />
<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
</button>
</span>
@ -58,7 +36,7 @@
</div>
<div class="form-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 class="form-group">
@ -67,7 +45,7 @@
</div>
<div class="form-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>
</form>