mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2024-11-19 01:43:50 +01:00
Asyncify SSH access, do not show SSH service if ssh is not well configured
This commit is contained in:
parent
9a9e31c759
commit
9688798a4a
@ -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;
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
118
BTCPayServer/Extensions/SSHClientExtensions.cs
Normal file
118
BTCPayServer/Extensions/SSHClientExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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>());
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
14
BTCPayServer/SSH/SSHCommandResult.cs
Normal file
14
BTCPayServer/SSH/SSHCommandResult.cs
Normal 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; }
|
||||
}
|
||||
}
|
@ -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()
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user