using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Mail; using System.Threading.Tasks; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Models; using BTCPayServer.Configuration; using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.HostedServices; using BTCPayServer.Hosting; using BTCPayServer.Logging; using BTCPayServer.Models; using BTCPayServer.Models.AccountViewModels; using BTCPayServer.Models.ServerViewModels; using BTCPayServer.Services; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Mails; using BTCPayServer.Services.Stores; using BTCPayServer.Storage.Models; using BTCPayServer.Storage.Services; using BTCPayServer.Storage.Services.Providers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NBitcoin; using NBitcoin.DataEncoders; using Renci.SshNet; using AuthenticationSchemes = BTCPayServer.Abstractions.Constants.AuthenticationSchemes; namespace BTCPayServer.Controllers { [Authorize(Policy = BTCPayServer.Client.Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public partial class ServerController : Controller { private readonly UserManager _UserManager; readonly SettingsRepository _SettingsRepository; private readonly NBXplorerDashboard _dashBoard; private readonly StoreRepository _StoreRepository; readonly LightningConfigurationProvider _LnConfigProvider; private readonly TorServices _torServices; private readonly BTCPayServerOptions _Options; private readonly AppService _AppService; private readonly CheckConfigurationHostedService _sshState; private readonly EventAggregator _eventAggregator; private readonly CssThemeManager _cssThemeManager; private readonly IOptions _externalServiceOptions; private readonly StoredFileRepository _StoredFileRepository; private readonly FileService _FileService; private readonly IEnumerable _StorageProviderServices; public ServerController(UserManager userManager, StoredFileRepository storedFileRepository, FileService fileService, IEnumerable storageProviderServices, BTCPayServerOptions options, SettingsRepository settingsRepository, NBXplorerDashboard dashBoard, IHttpClientFactory httpClientFactory, LightningConfigurationProvider lnConfigProvider, TorServices torServices, StoreRepository storeRepository, AppService appService, CheckConfigurationHostedService sshState, EventAggregator eventAggregator, CssThemeManager cssThemeManager, IOptions externalServiceOptions) { _Options = options; _StoredFileRepository = storedFileRepository; _FileService = fileService; _StorageProviderServices = storageProviderServices; _UserManager = userManager; _SettingsRepository = settingsRepository; _dashBoard = dashBoard; HttpClientFactory = httpClientFactory; _StoreRepository = storeRepository; _LnConfigProvider = lnConfigProvider; _torServices = torServices; _AppService = appService; _sshState = sshState; _eventAggregator = eventAggregator; _cssThemeManager = cssThemeManager; _externalServiceOptions = externalServiceOptions; } [Route("server/maintenance")] public IActionResult Maintenance() { MaintenanceViewModel vm = new MaintenanceViewModel(); vm.CanUseSSH = _sshState.CanUseSSH; if (!vm.CanUseSSH) TempData[WellKnownTempData.ErrorMessage] = "Maintenance feature requires access to SSH properly configured in BTCPayServer configuration."; vm.DNSDomain = this.Request.Host.Host; if (IPAddress.TryParse(vm.DNSDomain, out var unused)) vm.DNSDomain = null; return View(vm); } [Route("server/maintenance")] [HttpPost] public async Task Maintenance(MaintenanceViewModel vm, string command) { vm.CanUseSSH = _sshState.CanUseSSH; if (!vm.CanUseSSH) { TempData[WellKnownTempData.ErrorMessage] = "Maintenance feature requires access to SSH properly configured in BTCPayServer configuration."; return View(vm); } if (!ModelState.IsValid) return View(vm); if (command == "changedomain") { if (string.IsNullOrWhiteSpace(vm.DNSDomain)) { ModelState.AddModelError(nameof(vm.DNSDomain), $"Required field"); return View(vm); } vm.DNSDomain = vm.DNSDomain.Trim().ToLowerInvariant(); if (vm.DNSDomain.Equals(this.Request.Host.Host, StringComparison.OrdinalIgnoreCase)) return View(vm); if (IPAddress.TryParse(vm.DNSDomain, out var unused)) { ModelState.AddModelError(nameof(vm.DNSDomain), $"This should be a domain name"); return View(vm); } if (vm.DNSDomain.Equals(this.Request.Host.Host, StringComparison.InvariantCultureIgnoreCase)) { ModelState.AddModelError(nameof(vm.DNSDomain), $"The server is already set to use this domain"); return View(vm); } var builder = new UriBuilder(); using (var client = new HttpClient(new HttpClientHandler() { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator })) { try { builder.Scheme = this.Request.Scheme; builder.Host = vm.DNSDomain; var addresses1 = GetAddressAsync(this.Request.Host.Host); var addresses2 = GetAddressAsync(vm.DNSDomain); await Task.WhenAll(addresses1, addresses2); var addressesSet = addresses1.GetAwaiter().GetResult().Select(c => c.ToString()).ToHashSet(); var hasCommonAddress = addresses2.GetAwaiter().GetResult().Select(c => c.ToString()).Any(s => addressesSet.Contains(s)); if (!hasCommonAddress) { ModelState.AddModelError(nameof(vm.DNSDomain), $"Invalid host ({vm.DNSDomain} is not pointing to this BTCPay instance)"); return View(vm); } } catch (Exception ex) { var messages = new List(); messages.Add(ex.Message); if (ex.InnerException != null) messages.Add(ex.InnerException.Message); ModelState.AddModelError(nameof(vm.DNSDomain), $"Invalid domain ({string.Join(", ", messages.ToArray())})"); return View(vm); } } var error = await RunSSH(vm, $"changedomain.sh {vm.DNSDomain}"); if (error != null) return error; builder.Path = null; builder.Query = null; TempData[WellKnownTempData.SuccessMessage] = $"Domain name changing... the server will restart, please use \"{builder.Uri.AbsoluteUri}\" (this page won't reload automatically)"; } else if (command == "update") { var error = await RunSSH(vm, $"btcpay-update.sh"); if (error != null) return error; TempData[WellKnownTempData.SuccessMessage] = $"The server might restart soon if an update is available... (this page won't reload automatically)"; } else if (command == "clean") { var error = await RunSSH(vm, $"btcpay-clean.sh"); if (error != null) return error; TempData[WellKnownTempData.SuccessMessage] = $"The old docker images will be cleaned soon..."; } else if (command == "restart") { var error = await RunSSH(vm, $"btcpay-restart.sh"); if (error != null) return error; TempData[WellKnownTempData.SuccessMessage] = $"BTCPay will restart momentarily."; } else { return NotFound(); } return RedirectToAction(nameof(Maintenance)); } private Task GetAddressAsync(string domainOrIP) { if (IPAddress.TryParse(domainOrIP, out var ip)) return Task.FromResult(new[] { ip }); return Dns.GetHostAddressesAsync(domainOrIP); } public static string RunId = Encoders.Hex.EncodeData(NBitcoin.RandomUtils.GetBytes(32)); [HttpGet] [Route("runid")] [AllowAnonymous] public IActionResult SeeRunId(string expected = null) { if (expected == RunId) return Ok(); return BadRequest(); } private async Task RunSSH(MaintenanceViewModel vm, string command) { SshClient sshClient = null; try { sshClient = await _Options.SSHSettings.ConnectAsync(); } catch (Exception ex) { var message = ex.Message; if (ex is AggregateException aggrEx && aggrEx.InnerException?.Message != null) { message = aggrEx.InnerException.Message; } ModelState.AddModelError(string.Empty, $"Connection problem ({message})"); return View(vm); } _ = 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(); } } public IHttpClientFactory HttpClientFactory { get; } [Route("server/policies")] public async Task Policies() { var data = (await _SettingsRepository.GetSettingAsync()) ?? new PoliciesSettings(); ViewBag.AppsList = await GetAppSelectList(); ViewBag.UpdateUrlPresent = _Options.UpdateUrl != null; return View(data); } [Route("server/policies")] [HttpPost] public async Task Policies([FromServices] BTCPayNetworkProvider btcPayNetworkProvider,PoliciesSettings settings, string command = "") { ViewBag.UpdateUrlPresent = _Options.UpdateUrl != null; ViewBag.AppsList = await GetAppSelectList(); if (command == "add-domain") { ModelState.Clear(); settings.DomainToAppMapping.Add(new PoliciesSettings.DomainToAppMappingItem()); return View(settings); } if (command.StartsWith("remove-domain", StringComparison.InvariantCultureIgnoreCase)) { ModelState.Clear(); var index = int.Parse(command.Substring(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1), CultureInfo.InvariantCulture); settings.DomainToAppMapping.RemoveAt(index); return View(settings); } settings.BlockExplorerLinks = settings.BlockExplorerLinks.Where(tuple => btcPayNetworkProvider.GetNetwork(tuple.CryptoCode).BlockExplorerLinkDefault != tuple.Link).ToList(); if (!ModelState.IsValid) { return View(settings); } var appIdsToFetch = settings.DomainToAppMapping.Select(item => item.AppId).ToList(); if (!string.IsNullOrEmpty(settings.RootAppId)) { appIdsToFetch.Add(settings.RootAppId); } else { settings.RootAppType = null; } if (appIdsToFetch.Any()) { var apps = (await _AppService.GetApps(appIdsToFetch.ToArray())) .ToDictionary(data => data.Id, data => Enum.Parse(data.AppType)); ; if (!string.IsNullOrEmpty(settings.RootAppId)) { settings.RootAppType = apps[settings.RootAppId]; } foreach (var domainToAppMappingItem in settings.DomainToAppMapping) { domainToAppMappingItem.AppType = apps[domainToAppMappingItem.AppId]; } } await _SettingsRepository.UpdateSetting(settings); BlockExplorerLinkStartupTask.SetLinkOnNetworks(settings.BlockExplorerLinks, btcPayNetworkProvider); TempData[WellKnownTempData.SuccessMessage] = "Policies updated successfully"; return RedirectToAction(nameof(Policies)); } [Route("server/services")] public async Task Services() { var result = new ServicesViewModel(); result.ExternalServices = _externalServiceOptions.Value.ExternalServices.ToList(); // other services foreach (var externalService in _externalServiceOptions.Value.OtherExternalServices) { result.OtherExternalServices.Add(new ServicesViewModel.OtherExternalService() { Name = externalService.Key, Link = this.Request.GetAbsoluteUriNoPathBase(externalService.Value).AbsoluteUri }); } if (CanShowSSHService()) { result.OtherExternalServices.Add(new ServicesViewModel.OtherExternalService() { Name = "SSH", Link = this.Url.Action(nameof(SSHService)) }); } result.OtherExternalServices.Add(new ServicesViewModel.OtherExternalService() { Name = "Dynamic DNS", Link = this.Url.Action(nameof(DynamicDnsServices)) }); foreach (var torService in _torServices.Services) { if (torService.VirtualPort == 80) { result.TorHttpServices.Add(new ServicesViewModel.OtherExternalService() { Name = torService.Name, Link = $"http://{torService.OnionHost}" }); } else if (TryParseAsExternalService(torService, out var externalService)) { result.ExternalServices.Add(externalService); } else { result.TorOtherServices.Add(new ServicesViewModel.OtherExternalService() { Name = torService.Name, Link = $"{torService.OnionHost}:{torService.VirtualPort}" }); } } // external storage services var storageSettings = await _SettingsRepository.GetSettingAsync(); result.ExternalStorageServices.Add(new ServicesViewModel.OtherExternalService() { Name = storageSettings == null ? "Not set" : storageSettings.Provider.ToString(), Link = Url.Action("Storage") }); return View(result); } private async Task> GetAppSelectList() { var apps = (await _AppService.GetAllApps(null, true)) .Select(a => new SelectListItem($"{a.AppType} - {a.AppName} - {a.StoreName}", a.Id)).ToList(); apps.Insert(0, new SelectListItem("(None)", null)); return apps; } private static bool TryParseAsExternalService(TorService torService, out ExternalService externalService) { externalService = null; if (torService.ServiceType == TorServiceType.P2P) { externalService = new ExternalService() { CryptoCode = torService.Network.CryptoCode, DisplayName = "Full node P2P", Type = ExternalServiceTypes.P2P, ConnectionString = new ExternalConnectionString(new Uri($"bitcoin-p2p://{torService.OnionHost}:{torService.VirtualPort}", UriKind.Absolute)), ServiceName = torService.Name, }; } if (torService.ServiceType == TorServiceType.RPC) { externalService = new ExternalService() { CryptoCode = torService.Network.CryptoCode, DisplayName = "Full node RPC", Type = ExternalServiceTypes.RPC, ConnectionString = new ExternalConnectionString(new Uri($"btcrpc://btcrpc:btcpayserver4ever@{torService.OnionHost}:{torService.VirtualPort}?label=BTCPayNode", UriKind.Absolute)), ServiceName = torService.Name }; } return externalService != null; } private ExternalService GetService(string serviceName, string cryptoCode) { var result = _externalServiceOptions.Value.ExternalServices.GetService(serviceName, cryptoCode); if (result != null) return result; foreach (var torService in _torServices.Services) { if (TryParseAsExternalService(torService, out var torExternalService) && torExternalService.ServiceName == serviceName) return torExternalService; } return null; } [Route("server/services/{serviceName}/{cryptoCode?}")] public async Task Service(string serviceName, string cryptoCode, bool showQR = false, uint? nonce = null) { var service = GetService(serviceName, cryptoCode); if (service == null) return NotFound(); if (!string.IsNullOrEmpty(cryptoCode) && !_dashBoard.IsFullySynched(cryptoCode, out _) && service.Type != ExternalServiceTypes.RPC) { TempData[WellKnownTempData.ErrorMessage] = $"{cryptoCode} is not fully synched"; return RedirectToAction(nameof(Services)); } try { if (service.Type == ExternalServiceTypes.P2P) { return View("P2PService", new LightningWalletServices() { ShowQR = showQR, WalletName = service.ServiceName, ServiceLink = service.ConnectionString.Server.AbsoluteUri.WithoutEndingSlash() }); } if (service.Type == ExternalServiceTypes.LNDSeedBackup) { var model = LndSeedBackupViewModel.Parse(service.ConnectionString.CookieFilePath); if (!model.IsWalletUnlockPresent) { TempData.SetStatusMessageModel(new StatusMessageModel() { Severity = StatusMessageModel.StatusSeverity.Warning, Html = "Your LND does not seem to allow seed backup.
" + "It's recommended, but not required, that you migrate as instructed by our migration blog post.
" + "You will need to close all of your channels, and migrate your funds as we documented." }); } return View("LndSeedBackup", model); } if (service.Type == ExternalServiceTypes.RPC) { return View("RPCService", new LightningWalletServices() { ShowQR = showQR, WalletName = service.ServiceName, ServiceLink = service.ConnectionString.Server.AbsoluteUri.WithoutEndingSlash() }); } var connectionString = await service.ConnectionString.Expand(this.Request.GetAbsoluteUriNoPathBase(), service.Type, _Options.NetworkType); switch (service.Type) { case ExternalServiceTypes.Charge: return LightningChargeServices(service, connectionString, showQR); case ExternalServiceTypes.RTL: case ExternalServiceTypes.ThunderHub: case ExternalServiceTypes.Spark: if (connectionString.AccessKey == null) { TempData[WellKnownTempData.ErrorMessage] = $"The access key of the service is not set"; return RedirectToAction(nameof(Services)); } LightningWalletServices vm = new LightningWalletServices(); vm.ShowQR = showQR; vm.WalletName = service.DisplayName; string tokenParam = "access-key"; if (service.Type == ExternalServiceTypes.ThunderHub) tokenParam = "token"; vm.ServiceLink = $"{connectionString.Server}?{tokenParam}={connectionString.AccessKey}"; return View("LightningWalletServices", vm); case ExternalServiceTypes.CLightningRest: return LndServices(service, connectionString, nonce, "CLightningRestServices"); case ExternalServiceTypes.LNDGRPC: case ExternalServiceTypes.LNDRest: return LndServices(service, connectionString, nonce); case ExternalServiceTypes.Configurator: return View("ConfiguratorService", new LightningWalletServices() { ShowQR = showQR, WalletName = service.ServiceName, ServiceLink = $"{connectionString.Server}?password={connectionString.AccessKey}" }); default: throw new NotSupportedException(service.Type.ToString()); } } catch (Exception ex) { TempData[WellKnownTempData.ErrorMessage] = ex.Message; return RedirectToAction(nameof(Services)); } } [HttpGet] [Route("server/services/{serviceName}/{cryptoCode}/removelndseed")] public IActionResult RemoveLndSeed(string serviceName, string cryptoCode) { return View("Confirm", new ConfirmModel() { Title = "Delete LND Seed", Description = "Please make sure you made a backup of the seed and password before deleting the LND backup seed from the server, are you sure to continue?", Action = "Delete" }); } [HttpPost] [Route("server/services/{serviceName}/{cryptoCode}/removelndseed")] public async Task RemoveLndSeedPost(string serviceName, string cryptoCode) { var service = GetService(serviceName, cryptoCode); if (service == null) return NotFound(); var model = LndSeedBackupViewModel.Parse(service.ConnectionString.CookieFilePath); if (!model.IsWalletUnlockPresent) { TempData[WellKnownTempData.ErrorMessage] = $"File with wallet password and seed info not present"; return RedirectToAction(nameof(Services)); } if (string.IsNullOrEmpty(model.Seed)) { TempData[WellKnownTempData.ErrorMessage] = $"Seed information was already removed"; return RedirectToAction(nameof(Services)); } if (await model.RemoveSeedAndWrite(service.ConnectionString.CookieFilePath)) { TempData[WellKnownTempData.SuccessMessage] = $"Seed successfully removed"; return RedirectToAction(nameof(Service), new { serviceName, cryptoCode }); } else { TempData[WellKnownTempData.ErrorMessage] = $"Seed removal failed"; return RedirectToAction(nameof(Services)); } } private IActionResult LightningChargeServices(ExternalService service, ExternalConnectionString connectionString, bool showQR = false) { ChargeServiceViewModel vm = new ChargeServiceViewModel(); vm.Uri = connectionString.Server.AbsoluteUri; vm.APIToken = connectionString.APIToken; var builder = new UriBuilder(connectionString.Server); builder.UserName = "api-token"; builder.Password = vm.APIToken; vm.AuthenticatedUri = builder.ToString(); return View(nameof(LightningChargeServices), vm); } private IActionResult LndServices(ExternalService service, ExternalConnectionString connectionString, uint? nonce, string view = nameof(LndServices)) { var model = new LndServicesViewModel(); if (service.Type == ExternalServiceTypes.LNDGRPC) { model.Host = $"{connectionString.Server.DnsSafeHost}:{connectionString.Server.Port}"; model.SSL = connectionString.Server.Scheme == "https"; model.ConnectionType = "GRPC"; model.GRPCSSLCipherSuites = "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256"; } else if (service.Type == ExternalServiceTypes.LNDRest || service.Type == ExternalServiceTypes.CLightningRest) { model.Uri = connectionString.Server.AbsoluteUri; model.ConnectionType = "REST"; } if (connectionString.CertificateThumbprint != null) { model.CertificateThumbprint = connectionString.CertificateThumbprint; } if (connectionString.Macaroon != null) { model.Macaroon = Encoders.Hex.EncodeData(connectionString.Macaroon); } model.AdminMacaroon = connectionString.Macaroons?.AdminMacaroon?.Hex; model.InvoiceMacaroon = connectionString.Macaroons?.InvoiceMacaroon?.Hex; model.ReadonlyMacaroon = connectionString.Macaroons?.ReadonlyMacaroon?.Hex; if (nonce != null) { var configKey = GetConfigKey("lnd", service.ServiceName, service.CryptoCode, nonce.Value); var lnConfig = _LnConfigProvider.GetConfig(configKey); if (lnConfig != null) { model.QRCodeLink = Request.GetAbsoluteUri(Url.Action(nameof(GetLNDConfig), new { configKey = configKey })); model.QRCode = $"config={model.QRCodeLink}"; } } return View(view, model); } private static uint GetConfigKey(string type, string serviceName, string cryptoCode, uint nonce) { return (uint)HashCode.Combine(type, serviceName, cryptoCode, nonce); } [Route("lnd-config/{configKey}/lnd.config")] [AllowAnonymous] public IActionResult GetLNDConfig(uint configKey) { var conf = _LnConfigProvider.GetConfig(configKey); if (conf == null) return NotFound(); return Json(conf); } [Route("server/services/{serviceName}/{cryptoCode}")] [HttpPost] public async Task ServicePost(string serviceName, string cryptoCode) { if (!_dashBoard.IsFullySynched(cryptoCode, out var unusud)) { TempData[WellKnownTempData.ErrorMessage] = $"{cryptoCode} is not fully synched"; return RedirectToAction(nameof(Services)); } var service = GetService(serviceName, cryptoCode); if (service == null) return NotFound(); ExternalConnectionString connectionString = null; try { connectionString = await service.ConnectionString.Expand(this.Request.GetAbsoluteUriNoPathBase(), service.Type, _Options.NetworkType); } catch (Exception ex) { TempData[WellKnownTempData.ErrorMessage] = ex.Message; return RedirectToAction(nameof(Services)); } LightningConfigurations confs = new LightningConfigurations(); if (service.Type == ExternalServiceTypes.LNDGRPC) { LightningConfiguration grpcConf = new LightningConfiguration(); grpcConf.Type = "grpc"; grpcConf.Host = connectionString.Server.DnsSafeHost; grpcConf.Port = connectionString.Server.Port; grpcConf.SSL = connectionString.Server.Scheme == "https"; confs.Configurations.Add(grpcConf); } else if (service.Type == ExternalServiceTypes.LNDRest || service.Type == ExternalServiceTypes.CLightningRest) { var restconf = new LNDRestConfiguration(); restconf.Type = service.Type == ExternalServiceTypes.LNDRest ? "lnd-rest" : "clightning-rest"; restconf.Uri = connectionString.Server.AbsoluteUri; confs.Configurations.Add(restconf); } else throw new NotSupportedException(service.Type.ToString()); var commonConf = (LNDConfiguration)confs.Configurations[confs.Configurations.Count - 1]; commonConf.ChainType = _Options.NetworkType.ToString(); commonConf.CryptoCode = cryptoCode; commonConf.Macaroon = connectionString.Macaroon == null ? null : Encoders.Hex.EncodeData(connectionString.Macaroon); commonConf.CertificateThumbprint = connectionString.CertificateThumbprint == null ? null : connectionString.CertificateThumbprint; commonConf.AdminMacaroon = connectionString.Macaroons?.AdminMacaroon?.Hex; commonConf.ReadonlyMacaroon = connectionString.Macaroons?.ReadonlyMacaroon?.Hex; commonConf.InvoiceMacaroon = connectionString.Macaroons?.InvoiceMacaroon?.Hex; var nonce = RandomUtils.GetUInt32(); var configKey = GetConfigKey("lnd", serviceName, cryptoCode, nonce); _LnConfigProvider.KeepConfig(configKey, confs); return RedirectToAction(nameof(Service), new { cryptoCode = cryptoCode, serviceName = serviceName, nonce = nonce }); } [Route("server/services/dynamic-dns")] public async Task DynamicDnsServices() { var settings = (await _SettingsRepository.GetSettingAsync()) ?? new DynamicDnsSettings(); return View(settings.Services.Select(s => new DynamicDnsViewModel() { Settings = s }).ToArray()); } [Route("server/services/dynamic-dns/{hostname}")] public async Task DynamicDnsServices(string hostname) { var settings = (await _SettingsRepository.GetSettingAsync()) ?? new DynamicDnsSettings(); var service = settings.Services.FirstOrDefault(s => s.Hostname.Equals(hostname, StringComparison.OrdinalIgnoreCase)); if (service == null) return NotFound(); var vm = new DynamicDnsViewModel(); vm.Modify = true; vm.Settings = service; return View(nameof(DynamicDnsService), vm); } [Route("server/services/dynamic-dns")] [HttpPost] public async Task DynamicDnsService(DynamicDnsViewModel viewModel, string command = null) { if (!ModelState.IsValid) { return View(viewModel); } if (command == "Save") { var settings = (await _SettingsRepository.GetSettingAsync()) ?? new DynamicDnsSettings(); var i = settings.Services.FindIndex(d => d.Hostname.Equals(viewModel.Settings.Hostname, StringComparison.OrdinalIgnoreCase)); if (i != -1) { ModelState.AddModelError(nameof(viewModel.Settings.Hostname), "This hostname already exists"); return View(viewModel); } if (viewModel.Settings.Hostname != null) viewModel.Settings.Hostname = viewModel.Settings.Hostname.Trim().ToLowerInvariant(); string errorMessage = await viewModel.Settings.SendUpdateRequest(HttpClientFactory.CreateClient()); if (errorMessage == null) { TempData[WellKnownTempData.SuccessMessage] = $"The Dynamic DNS has been successfully queried, your configuration is saved"; viewModel.Settings.LastUpdated = DateTimeOffset.UtcNow; settings.Services.Add(viewModel.Settings); await _SettingsRepository.UpdateSetting(settings); return RedirectToAction(nameof(DynamicDnsServices)); } else { ModelState.AddModelError(string.Empty, errorMessage); return View(viewModel); } } else { return View(new DynamicDnsViewModel() { Settings = new DynamicDnsService() }); } } [Route("server/services/dynamic-dns/{hostname}")] [HttpPost] public async Task DynamicDnsService(DynamicDnsViewModel viewModel, string hostname, string command = null) { if (!ModelState.IsValid) { return View(viewModel); } var settings = (await _SettingsRepository.GetSettingAsync()) ?? new DynamicDnsSettings(); var i = settings.Services.FindIndex(d => d.Hostname.Equals(hostname, StringComparison.OrdinalIgnoreCase)); if (i == -1) return NotFound(); if (viewModel.Settings.Password == null) viewModel.Settings.Password = settings.Services[i].Password; if (viewModel.Settings.Hostname != null) viewModel.Settings.Hostname = viewModel.Settings.Hostname.Trim().ToLowerInvariant(); if (!viewModel.Settings.Enabled) { TempData[WellKnownTempData.SuccessMessage] = $"The Dynamic DNS service has been disabled"; viewModel.Settings.LastUpdated = null; } else { string errorMessage = await viewModel.Settings.SendUpdateRequest(HttpClientFactory.CreateClient()); if (errorMessage == null) { TempData[WellKnownTempData.SuccessMessage] = $"The Dynamic DNS has been successfully queried, your configuration is saved"; viewModel.Settings.LastUpdated = DateTimeOffset.UtcNow; } else { ModelState.AddModelError(string.Empty, errorMessage); return View(viewModel); } } settings.Services[i] = viewModel.Settings; await _SettingsRepository.UpdateSetting(settings); this.RouteData.Values.Remove(nameof(hostname)); return RedirectToAction(nameof(DynamicDnsServices)); } [HttpGet] [Route("server/services/dynamic-dns/{hostname}/delete")] public async Task DeleteDynamicDnsService(string hostname) { var settings = (await _SettingsRepository.GetSettingAsync()) ?? new DynamicDnsSettings(); var i = settings.Services.FindIndex(d => d.Hostname.Equals(hostname, StringComparison.OrdinalIgnoreCase)); if (i == -1) return NotFound(); return View("Confirm", new ConfirmModel() { Title = "Delete the dynamic dns service for " + hostname, Description = "BTCPayServer will stop updating this DNS record periodically", Action = "Delete" }); } [HttpPost] [Route("server/services/dynamic-dns/{hostname}/delete")] public async Task DeleteDynamicDnsServicePost(string hostname) { var settings = (await _SettingsRepository.GetSettingAsync()) ?? new DynamicDnsSettings(); var i = settings.Services.FindIndex(d => d.Hostname.Equals(hostname, StringComparison.OrdinalIgnoreCase)); if (i == -1) return NotFound(); settings.Services.RemoveAt(i); await _SettingsRepository.UpdateSetting(settings); TempData[WellKnownTempData.SuccessMessage] = "Dynamic DNS service successfully removed"; this.RouteData.Values.Remove(nameof(hostname)); return RedirectToAction(nameof(DynamicDnsServices)); } [Route("server/services/ssh")] public async Task SSHService() { if (!CanShowSSHService()) return NotFound(); var settings = _Options.SSHSettings; var server = Extensions.IsLocalNetwork(settings.Server) ? this.Request.Host.Host : settings.Server; SSHServiceViewModel vm = new SSHServiceViewModel(); string port = settings.Port == 22 ? "" : $" -p {settings.Port}"; vm.CommandLine = $"ssh {settings.Username}@{server}{port}"; vm.Password = settings.Password; vm.KeyFilePassword = settings.KeyFilePassword; vm.HasKeyFile = !string.IsNullOrEmpty(settings.KeyFile); // Let's try to just read the authorized key file if (CanAccessAuthorizedKeyFile()) { try { vm.SSHKeyFileContent = await System.IO.File.ReadAllTextAsync(settings.AuthorizedKeysFile); } catch { } } // If that fail, just fallback to ssh if (vm.SSHKeyFileContent == null && _sshState.CanUseSSH) { try { using (var sshClient = await _Options.SSHSettings.ConnectAsync()) { var result = await sshClient.RunBash("cat ~/.ssh/authorized_keys", TimeSpan.FromSeconds(10)); vm.SSHKeyFileContent = result.Output; } } catch { } } return View(vm); } bool CanShowSSHService() { return _Options.SSHSettings != null && (_sshState.CanUseSSH || CanAccessAuthorizedKeyFile()); } private bool CanAccessAuthorizedKeyFile() { return _Options.SSHSettings?.AuthorizedKeysFile != null && System.IO.File.Exists(_Options.SSHSettings.AuthorizedKeysFile); } [HttpPost] [Route("server/services/ssh")] public async Task SSHService(SSHServiceViewModel viewModel) { string newContent = viewModel?.SSHKeyFileContent ?? string.Empty; newContent = newContent.Replace("\r\n", "\n", StringComparison.OrdinalIgnoreCase); bool updated = false; Exception exception = null; // Let's try to just write the file if (CanAccessAuthorizedKeyFile()) { try { await System.IO.File.WriteAllTextAsync(_Options.SSHSettings.AuthorizedKeysFile, newContent); TempData[WellKnownTempData.SuccessMessage] = "authorized_keys has been updated"; updated = true; } catch (Exception ex) { exception = ex; } } // If that fail, fallback to ssh if (!updated && _sshState.CanUseSSH) { try { using (var sshClient = await _Options.SSHSettings.ConnectAsync()) { await sshClient.RunBash($"mkdir -p ~/.ssh && echo '{newContent.EscapeSingleQuotes()}' > ~/.ssh/authorized_keys", TimeSpan.FromSeconds(10)); } updated = true; exception = null; } catch (Exception ex) { exception = ex; } } if (exception is null) { TempData[WellKnownTempData.SuccessMessage] = "authorized_keys has been updated"; } else { TempData[WellKnownTempData.ErrorMessage] = exception.Message; } return RedirectToAction(nameof(SSHService)); } [Route("server/theme")] public async Task Theme() { var data = (await _SettingsRepository.GetSettingAsync()) ?? new ThemeSettings(); return View(data); } [Route("server/theme")] [HttpPost] public async Task Theme(ThemeSettings settings) { await _SettingsRepository.UpdateSetting(settings); TempData[WellKnownTempData.SuccessMessage] = "Theme settings updated successfully"; return View(settings); } [Route("server/emails")] public async Task Emails() { var data = (await _SettingsRepository.GetSettingAsync()) ?? new EmailSettings(); return View(new EmailsViewModel(data)); } [Route("server/emails")] [HttpPost] public async Task Emails(EmailsViewModel model, string command) { if (command == "Test") { try { if (model.PasswordSet) { var settings = await _SettingsRepository.GetSettingAsync(); model.Settings.Password = settings.Password; } if (!model.Settings.IsComplete()) { TempData[WellKnownTempData.ErrorMessage] = "Required fields missing"; return View(model); } using (var client = model.Settings.CreateSmtpClient()) using (var message = model.Settings.CreateMailMessage(new MailAddress(model.TestEmail), "BTCPay test", "BTCPay test")) { await client.SendMailAsync(message); } TempData[WellKnownTempData.SuccessMessage] = "Email sent to " + model.TestEmail + ", please, verify you received it"; } catch (Exception ex) { TempData[WellKnownTempData.ErrorMessage] = ex.Message; } return View(model); } else if (command == "ResetPassword") { var settings = await _SettingsRepository.GetSettingAsync(); settings.Password = null; await _SettingsRepository.UpdateSetting(model.Settings); TempData[WellKnownTempData.SuccessMessage] = "Email server password reset"; return RedirectToAction(nameof(Emails)); } else // if(command == "Save") { var oldSettings = await _SettingsRepository.GetSettingAsync(); if (new EmailsViewModel(oldSettings).PasswordSet) { model.Settings.Password = oldSettings.Password; } await _SettingsRepository.UpdateSetting(model.Settings); TempData[WellKnownTempData.SuccessMessage] = "Email settings saved"; return RedirectToAction(nameof(Emails)); } } [Route("server/logs/{file?}")] public async Task LogsView(string file = null, int offset = 0) { if (offset < 0) { offset = 0; } var vm = new LogsViewModel(); if (string.IsNullOrEmpty(_Options.LogFile)) { TempData[WellKnownTempData.ErrorMessage] = "File Logging Option not specified. " + "You need to set debuglog and optionally " + "debugloglevel in the configuration or through runtime arguments"; } else { var di = Directory.GetParent(_Options.LogFile); if (di == null) { TempData[WellKnownTempData.ErrorMessage] = "Could not load log files"; } var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(_Options.LogFile); var fileExtension = Path.GetExtension(_Options.LogFile) ?? string.Empty; var logFiles = di.GetFiles($"{fileNameWithoutExtension}*{fileExtension}"); vm.LogFileCount = logFiles.Length; vm.LogFiles = logFiles .OrderBy(info => info.LastWriteTime) .Skip(offset) .Take(5) .ToList(); vm.LogFileOffset = offset; if (string.IsNullOrEmpty(file) || !file.EndsWith(fileExtension, StringComparison.Ordinal)) return View("Logs", vm); vm.Log = ""; var fi = vm.LogFiles.FirstOrDefault(o => o.Name == file); if (fi == null) return NotFound(); try { using (var fileStream = new FileStream( fi.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { using (var reader = new StreamReader(fileStream)) { vm.Log = await reader.ReadToEndAsync(); } } } catch { return NotFound(); } } return View("Logs", vm); } } }