What changed, and why it matters
This commit removes the built-in SSH client from BTCPay Server and replaces it with a local helper program called 'btcpay-host'. Instead of the application opening SSH connections to the server host, it now runs a configurable local executable to perform maintenance tasks such as updating, restarting, changing the domain, and managing authorized SSH keys. This is a significant architectural change that reduces the application's direct network attack surface, but it also introduces a new local command-execution path that must be trusted and properly secured.
Operators and auditors should verify that the 'btcpay-host' executable path is controlled by the deployment and not writable by the application user, since BTCPay Server will now run it with elevated maintenance privileges. Review the Docker btcpay-host wrapper to ensure host SSH keys and BTCPAY_SSHCONNECTION are restricted. Confirm that authorization checks (Policies.CanModifyServerSettings) remain enforced on all maintenance endpoints, and monitor for any follow-up patches that harden argument escaping or timeout handling in ProcessRunner.
Security signals we found
Removal of SSH.NET dependency and direct SSH client code
Removal of SSH credential configuration options from BTCPayServerOptions and DefaultConfiguration
Removal of SSH fingerprint validation and trusted-fingerprint logic
Removal of PoliciesSettings.DisableSSHService flag
Introduction of ProcessRunner that executes a configurable external executable (btcpay-host) with administrator-supplied arguments
Maintenance actions (update, restart, clean, changedomain) now delegated to local host command helper
SSH authorized_keys management now performed via local host command helper instead of SSH session
New Docker entrypoint and btcpay-host wrapper that forwards commands over SSH using host's ssh binary
Soft-restart action remains in-process via IHostApplicationLifetime.StopApplication
Evidence from the diff
The patch deletes the SSH.NET dependency, all SSH connection/fingerprint classes, and the CheckConfigurationHostedService. It removes configuration options sshconnection, sshpassword, sshkeyfile, sshkeyfilepassword, sshauthorizedkeys, and sshtrustedfingerprints. Maintenance actions and SSH key management are moved to a new ‘Maintenance’ plugin that invokes a configurable ‘btcpay-host’ executable via ProcessRunner. A Docker wrapper script forwards btcpay-host calls over the host’s SSH client using BTCPAY_SSHCONNECTION/BTCPAY_SSHKEYFILE. The UI no longer exposes SSH credentials or a disable-SSH toggle; instead it shows/hides actions based on the commands reported by btcpay-host commands.
Changed components
BTCPayServer/Configuration/BTCPayServerOptions.csBTCPayServer/Configuration/DefaultConfiguration.csBTCPayServer/Controllers/UIServerController.csBTCPayServer/HostedServices/CheckConfigurationHostedService.csBTCPayServer/Hosting/BTCPayServerServices.csBTCPayServer/Services/ProcessRunner.csBTCPayServer/Plugins/Maintenance/*BTCPayServer/Views/UIServer/SSHService.cshtmlDocker/btcpay-hostDocker/docker-entrypoint.shDockerfileInspect captured patch +772 / −971
### BTCPayServer.Tests/BTCPayServerTester.cs
@@ -162,13 +162,8 @@ public async Task StartAsync()
config.AppendLine($"socksendpoint={SocksEndpoint}");
config.AppendLine($"debuglog=debug.log");
config.AppendLine($"nocsp={NoCSP.ToString().ToLowerInvariant()}");
-
- if (!string.IsNullOrEmpty(SSHPassword) && string.IsNullOrEmpty(SSHKeyFile))
- config.AppendLine($"sshpassword={SSHPassword}");
- if (!string.IsNullOrEmpty(SSHKeyFile))
- config.AppendLine($"sshkeyfile={SSHKeyFile}");
- if (!string.IsNullOrEmpty(SSHConnection))
- config.AppendLine($"sshconnection={SSHConnection}");
+ if (btcpayHostExecutable is not null)
+ config.AppendLine($"btcpayhostexecutable={btcpayHostExecutable}");
if (!String.IsNullOrEmpty(Postgres))
config.AppendLine($"postgres=" + Postgres);
@@ -189,6 +184,7 @@ public async Task StartAsync()
#if DEBUG
confBuilder.AddJsonFile("appsettings.dev.json", true, false);
#endif
+
if (LoadPluginsInDefaultAssemblyContext)
confBuilder.AddInMemoryCollection([new("TEST_RUNNER_ENABLED", "true")]);
var conf = confBuilder.Build();
@@ -314,6 +310,8 @@ public async Task StartAsync()
TestLogs.LogInformation("Site is now operational");
}
MockRateProvider coinAverageMock;
+ public string btcpayHostExecutable;
+
private async Task WaitSiteIsOperational()
{
// Opportunistic call to wake up view compilation in debug mode, we don't need to await.
@@ -381,9 +379,6 @@ public string HostName
public IServiceProvider ServiceProvider => _Host.Services;
- public string SSHPassword { get; internal set; }
- public string SSHKeyFile { get; internal set; }
- public string SSHConnection { get; set; }
public bool NoCSP { get; set; }
public string HostEnvironment { get; set; } = Environments.Development;
public bool RuntimeCompilation { get; set; }
### BTCPayServer.Tests/FastTests.cs
@@ -1674,22 +1674,6 @@ internal static UIWalletsController.WalletTransactionsFilter BuildWalletTransact
return UIWalletsController.BuildWalletTransactionsFilter(search);
}
- [Fact]
- public void CanParseFingerprint()
- {
- Assert.True(SSH.SSHFingerprint.TryParse("4e343c6fc6cfbf9339c02d06a151e1dd", out var unused));
- Assert.Equal("4e:34:3c:6f:c6:cf:bf:93:39:c0:2d:06:a1:51:e1:dd", unused.ToString());
- Assert.True(SSH.SSHFingerprint.TryParse("4e:34:3c:6f:c6:cf:bf:93:39:c0:2d:06:a1:51:e1:dd", out unused));
- Assert.True(SSH.SSHFingerprint.TryParse("SHA256:Wl7CdRgT4u5T7yPMsxSrlFP+HIJJWwidGkzphJ8di5w", out unused));
- Assert.True(SSH.SSHFingerprint.TryParse("SHA256:Wl7CdRgT4u5T7yPMsxSrlFP+HIJJWwidGkzphJ8di5w=", out unused));
- Assert.True(SSH.SSHFingerprint.TryParse("Wl7CdRgT4u5T7yPMsxSrlFP+HIJJWwidGkzphJ8di5w=", out unused));
- Assert.Equal("SHA256:Wl7CdRgT4u5T7yPMsxSrlFP+HIJJWwidGkzphJ8di5w", unused.ToString());
-
- Assert.True(SSH.SSHFingerprint.TryParse("Wl7CdRgT4u5T7yPMsxSrlFP+HIJJWwidGkzphJ8di5w=", out var f1));
- Assert.True(SSH.SSHFingerprint.TryParse("SHA256:Wl7CdRgT4u5T7yPMsxSrlFP+HIJJWwidGkzphJ8di5w", out var f2));
- Assert.Equal(f1.ToString(), f2.ToString());
- }
-
[Fact]
public void HasCurrencyDataForNetworks()
{
### BTCPayServer.Tests/GlobalSearchTests.cs
@@ -19,6 +19,7 @@ public class GlobalSearchTests(ITestOutputHelper helper) : UnitTestBase(helper)
public async Task TestGlobalSearch()
{
await using var s = CreatePlaywrightTester();
+ await s.Server.InstallHostCommands();
await s.StartAsync();
await s.RegisterNewUser(isAdmin: true);
await s.CreateNewStore();
### BTCPayServer.Tests/PlaywrightTests.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
+using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
@@ -48,6 +49,7 @@ public class PlaywrightTests(ITestOutputHelper helper) : UnitTestBase(helper)
public async Task CanNavigateServerSettings()
{
await using var s = CreatePlaywrightTester();
+ await s.Server.InstallHostCommands();
await s.StartAsync();
await s.RegisterNewUser(true);
await s.SkipWizard();
@@ -875,23 +877,12 @@ async Task AssertCanAccessServerSettings(IPage page, bool expected)
public async Task CanUseSSHService()
{
await using var s = CreatePlaywrightTester();
+ await s.Server.InstallHostCommands();
await s.StartAsync();
- var settings = s.Server.PayTester.GetService<SettingsRepository>();
- var policies = await settings.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
- policies.DisableSSHService = false;
- await settings.UpdateSetting(policies);
await s.RegisterNewUser(isAdmin: true);
await s.GoToUrl("/server/services");
await s.Page.WaitForLoadStateAsync();
Assert.Contains("server/services/ssh", await s.Page.ContentAsync());
- using (var client = await s.Server.PayTester.GetService<BTCPayServerOptions>().SSHSettings
- .ConnectAsync())
- {
- var result = await client.RunBash("echo hello");
- Assert.Equal(string.Empty, result.Error);
- Assert.Equal("hello\n", result.Output);
- Assert.Equal(0, result.ExitStatus);
- }
await s.GoToUrl("/server/services/ssh");
await s.Page.AssertNoError();
@@ -911,20 +902,6 @@ public async Task CanUseSSHService()
text = await s.Page.Locator("#SSHKeyFileContent").TextContentAsync();
Assert.DoesNotContain("test2", text);
-
- // Let's try to disable it now
- await s.Page.ClickAsync("#disable");
- await s.Page.FillAsync("#ConfirmInput", "DISABLE");
- await s.Page.ClickAsync("#ConfirmContinue");
- await s.GoToUrl("/server/services/ssh", true);
- Assert.True((await s.Page.ContentAsync()).Contains("404 - Page not found", StringComparison.OrdinalIgnoreCase));
-
- policies = await settings.GetSettingAsync<PoliciesSettings>();
- Assert.NotNull(policies);
- Assert.True(policies.DisableSSHService);
-
- policies.DisableSSHService = false;
- await settings.UpdateSetting(policies);
}
[Fact]
### BTCPayServer.Tests/ServerTester.cs
@@ -77,9 +77,6 @@ public ServerTester(string scope, bool newDb, ILog testLogs, ILoggerProvider log
PayTester.HostName = GetEnvironment("TESTS_HOSTNAME", "127.0.0.1");
PayTester.InContainer = bool.Parse(GetEnvironment("TESTS_INCONTAINER", "false"));
- PayTester.SSHPassword = GetEnvironment("TESTS_SSHPASSWORD", "opD3i2282D");
- PayTester.SSHKeyFile = GetEnvironment("TESTS_SSHKEYFILE", "");
- PayTester.SSHConnection = GetEnvironment("TESTS_SSHCONNECTION", "root@127.0.0.1:21622");
PayTester.SocksEndpoint = GetEnvironment("TESTS_SOCKSENDPOINT", "localhost:9050");
}
@@ -311,5 +308,63 @@ public MailPitClient GetMailPitClient()
var mailPitClient = new MailPitClient(http);
return mailPitClient;
}
+
+ public async Task InstallHostCommands()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ var btcpayHostCmd = Path.Combine(_Directory, "btcpay-host.cmd");
+ await File.WriteAllTextAsync(btcpayHostCmd, """
+ @echo off
+ if "%~1" == "commands" (
+ echo ["showauthorizedkeys","setauthorizedkeys"]
+ exit /b 0
+ )
+ if "%~1" == "showauthorizedkeys" (
+ powershell -NoProfile -Command "if (Test-Path '%~dp0authorized_keys') { Get-Content -Raw '%~dp0authorized_keys' | ConvertTo-Json -Compress } else { '' | ConvertTo-Json -Compress }"
+ exit /b 0
+ )
+ if "%~1" == "setauthorizedkeys" (
+ <nul set /p="%~2" > "%~dp0authorized_keys"
+ exit /b 0
+ )
+ echo Unsupported host command: %~1 1>&2
+ exit /b 1
+ """);
+ PayTester.btcpayHostExecutable = btcpayHostCmd;
+ }
+ else
+ {
+ var btcpayHost = Path.Combine(_Directory, "btcpay-host");
+ await File.WriteAllTextAsync(btcpayHost, """
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ authorized_keys_file="$(dirname "$0")/authorized_keys"
+
+ case "$1" in
+ commands)
+ printf '["showauthorizedkeys","setauthorizedkeys"]\n'
+ ;;
+ showauthorizedkeys)
+ value="$(cat "$authorized_keys_file" 2>/dev/null || true)"
+ value="${value//\\/\\\\}"
+ value="${value//\"/\\\"}"
+ value="${value//$'\n'/\\n}"
+ printf '"%s"\n' "$value"
+ ;;
+ setauthorizedkeys)
+ printf '%s' "$2" > "$authorized_keys_file"
+ ;;
+ *)
+ echo "Unsupported host command: $1" >&2
+ exit 1
+ ;;
+ esac
+ """);
+ File.SetUnixFileMode(btcpayHost, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ PayTester.btcpayHostExecutable = btcpayHost;
+ }
+ }
}
}
### BTCPayServer.Tests/docker-compose.altcoins.yml
@@ -29,9 +29,6 @@ services:
TEST_MERCHANTLND: "http://merchant_lnd:8080/"
TESTS_INCONTAINER: "true"
PLAYWRIGHT_HEADLESS: "true"
- TESTS_SSHCONNECTION: "root@sshd:22"
- TESTS_SSHPASSWORD: ""
- TESTS_SSHKEYFILE: ""
TESTS_SOCKSENDPOINT: "tor:9050"
TESTS_ARTIFACTS_DIR: "/tmp/Artifacts"
expose:
@@ -45,7 +42,6 @@ services:
custom:
ipv4_address: 172.23.0.18
volumes:
- - "sshd_datadir:/root/.ssh"
- "customer_lightningd_datadir:/etc/customer_lightningd_datadir"
- "merchant_lightningd_datadir:/etc/merchant_lightningd_datadir"
- "tests_datadir:/tmp/Artifacts"
@@ -61,21 +57,9 @@ services:
- merchant_lightningd
- customer_lnd
- merchant_lnd
- - sshd
- tor
- mailpit
- sshd:
- build:
- context: .
- dockerfile: sshd.Dockerfile
- ports:
- - "21622:22"
- expose:
- - 22
- volumes:
- - "sshd_datadir:/root/.ssh"
-
devlnd:
image: btcpayserver/bitcoin:31.0
environment:
@@ -380,7 +364,6 @@ services:
volumes:
tests_datadir:
- sshd_datadir:
bitcoin_datadir:
elementsd_liquid_datadir:
customer_lightningd_datadir:
### BTCPayServer.Tests/docker-compose.mutinynet.yml
@@ -16,20 +16,8 @@ services:
- merchant_lightningd
- customer_lnd
- merchant_lnd
- - sshd
- tor
- sshd:
- build:
- context: .
- dockerfile: sshd.Dockerfile
- ports:
- - "21622:22"
- expose:
- - 22
- volumes:
- - "sshd_datadir:/root/.ssh"
-
devlnd:
image: btcpayserver/mutinynet:c23afab47fbe
environment:
@@ -268,7 +256,6 @@ services:
- "tor_servicesdir:/var/lib/tor/hidden_services"
volumes:
- sshd_datadir:
bitcoin_datadir:
elementsd_liquid_datadir:
customer_lightningd_datadir:
### BTCPayServer.Tests/docker-compose.testnet.yml
@@ -16,20 +16,8 @@ services:
- merchant_lightningd
- customer_lnd
- merchant_lnd
- - sshd
- tor
- sshd:
- build:
- context: .
- dockerfile: sshd.Dockerfile
- ports:
- - "21622:22"
- expose:
- - 22
- volumes:
- - "sshd_datadir:/root/.ssh"
-
devlnd:
image: btcpayserver/bitcoin:31.0
environment:
@@ -260,7 +248,6 @@ services:
- "tor_servicesdir:/var/lib/tor/hidden_services"
volumes:
- sshd_datadir:
bitcoin_datadir:
elementsd_liquid_datadir:
customer_lightningd_datadir:
### BTCPayServer.Tests/docker-compose.yml
@@ -27,9 +27,6 @@ services:
TEST_MERCHANTLND: "http://merchant_lnd:8080/"
TESTS_INCONTAINER: "true"
PLAYWRIGHT_HEADLESS: "true"
- TESTS_SSHCONNECTION: "root@sshd:22"
- TESTS_SSHPASSWORD: ""
- TESTS_SSHKEYFILE: ""
TESTS_SOCKSENDPOINT: "tor:9050"
expose:
- "80"
@@ -42,7 +39,6 @@ services:
custom:
ipv4_address: 172.23.0.18
volumes:
- - "sshd_datadir:/root/.ssh"
- "customer_lightningd_datadir:/etc/customer_lightningd_datadir"
- "merchant_lightningd_datadir:/etc/merchant_lightningd_datadir"
@@ -57,21 +53,9 @@ services:
- merchant_lightningd
- customer_lnd
- merchant_lnd
- - sshd
- tor
- mailpit
- sshd:
- build:
- context: .
- dockerfile: sshd.Dockerfile
- ports:
- - "21622:22"
- expose:
- - 22
- volumes:
- - "sshd_datadir:/root/.ssh"
-
devlnd:
image: btcpayserver/bitcoin:31.0
environment:
@@ -315,7 +299,6 @@ services:
- "tor_servicesdir:/var/lib/tor/hidden_services"
volumes:
- sshd_datadir:
bitcoin_datadir:
elementsd_liquid_datadir:
customer_lightningd_datadir:
### BTCPayServer.Tests/sshd.Dockerfile
@@ -1,12 +0,0 @@
-FROM alpine:3.8
-
-RUN apk add --no-cache openssh sudo bash
-RUN ssh-keygen -f /root/.ssh/id_rsa -t rsa -q -P "" -m PEM
-RUN echo 'root:opD3i2282D' | chpasswd
-RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config
-RUN ssh-keygen -f /etc/ssh/ssh_host_rsa_key -N '' -t rsa && \
- ssh-keygen -f /etc/ssh/ssh_host_dsa_key -N '' -t dsa && \
- ssh-keygen -f /etc/ssh/ssh_host_ecdsa_key -N '' -t ecdsa && \
- ssh-keygen -f /etc/ssh/ssh_host_ed25519_key -N '' -t ed25519
-
-CMD ["/usr/sbin/sshd", "-D"]
### BTCPayServer/BTCPayServer.csproj
@@ -53,7 +53,6 @@
<PackageReference Include="Serilog" Version="4.4.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
- <PackageReference Include="SSH.NET" Version="2026.0.0" />
<PackageReference Include="TwentyTwenty.Storage" Version="2.26.1" />
<PackageReference Include="TwentyTwenty.Storage.Amazon" Version="2.26.1" />
<PackageReference Include="TwentyTwenty.Storage.Azure" Version="2.26.1" />
### BTCPayServer/Components/GlobalNav/Default.cshtml
@@ -89,12 +89,6 @@
<li>
<a id="menu-item-@nameof(ServerNavPages.Translations)" class="dropdown-item@(activePage == nameof(ServerNavPages.Translations) ? " active" : "")" asp-area="@TranslationsPlugin.Area" asp-controller="UITranslation" asp-action="ListTranslations" text-translate="true">Translations</a>
</li>
- @if (Model.DockerDeployment)
- {
- <li>
- <a id="menu-item-@nameof(ServerNavPages.Maintenance)" class="dropdown-item@(activePage == nameof(ServerNavPages.Maintenance) ? " active" : "")" asp-controller="UIServer" asp-action="Maintenance" text-translate="true">Maintenance</a>
- </li>
- }
<li>
<a id="menu-item-@nameof(ServerNavPages.Logs)" class="dropdown-item@(activePage == nameof(ServerNavPages.Logs) ? " active" : "")" asp-controller="UIServer" asp-action="LogsView" text-translate="true">Logs</a>
</li>
### BTCPayServer/Components/GlobalNav/GlobalNav.cs
@@ -1,7 +1,6 @@
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Components.MainNav;
-using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Identity;
@@ -12,8 +11,7 @@ namespace BTCPayServer.Components.GlobalNav
public class GlobalNav(
UserManager<ApplicationUser> userManager,
UriResolver uriResolver,
- SettingsRepository settingsRepository,
- BTCPayServerOptions btcPayServerOptions)
+ SettingsRepository settingsRepository)
: ViewComponent
{
public async Task<IViewComponentResult> InvokeAsync()
@@ -23,7 +21,6 @@ public async Task<IViewComponentResult> InvokeAsync()
var vm = new GlobalNavViewModel
{
ContactUrl = serverSettings.ContactUrl,
- DockerDeployment = btcPayServerOptions.DockerDeployment,
CurrentStoreId = store?.Id,
MainNav = new MainNavViewModel
{
### BTCPayServer/Components/GlobalNav/GlobalNavViewModel.cs
@@ -7,7 +7,6 @@ public class GlobalNavViewModel
public string UserName { get; set; }
public string UserImageUrl { get; set; }
public string ContactUrl { get; set; }
- public bool DockerDeployment { get; set; }
public string CurrentStoreId { get; set; }
public MainNavViewModel MainNav { get; set; }
}
### BTCPayServer/Configuration/BTCPayServerOptions.cs
@@ -2,7 +2,6 @@
using System.IO;
using System.Net;
using BTCPayServer.Logging;
-using BTCPayServer.SSH;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NBitcoin;
@@ -95,49 +94,6 @@ public void LoadArgs(IConfiguration conf, Logs Logs)
UpdateUrl = conf.GetOrDefault<Uri>("updateurl", null);
- var sshSettings = ParseSSHConfiguration(conf);
- if ((!string.IsNullOrEmpty(sshSettings.Password) || !string.IsNullOrEmpty(sshSettings.KeyFile)) && !string.IsNullOrEmpty(sshSettings.Server))
- {
- int waitTime = 0;
- while (!string.IsNullOrEmpty(sshSettings.KeyFile) && !File.Exists(sshSettings.KeyFile))
- {
- if (waitTime++ < 5)
- System.Threading.Thread.Sleep(1000);
- else
- throw new ConfigException($"sshkeyfile does not exist");
- }
-
- if (sshSettings.Port > ushort.MaxValue ||
- sshSettings.Port < ushort.MinValue)
- throw new ConfigException($"ssh port is invalid");
- if (!string.IsNullOrEmpty(sshSettings.Password) && !string.IsNullOrEmpty(sshSettings.KeyFile))
- throw new ConfigException($"sshpassword or sshkeyfile should be provided, but not both");
- try
- {
- sshSettings.CreateConnectionInfo();
- SSHSettings = sshSettings;
- }
- catch (NotSupportedException ex)
- {
- Logs.Configuration.LogWarning($"The SSH key is not supported ({ex.Message}), try to generate the key with ssh-keygen using \"-m PEM\". Skipping SSH configuration...");
- }
- catch (Exception ex)
- {
- Logs.Configuration.LogWarning(ex, "Error while loading SSH settings");
- }
- }
-
- var fingerPrints = conf.GetOrDefault<string>("sshtrustedfingerprints", "");
- if (!string.IsNullOrEmpty(fingerPrints))
- {
- foreach (var fingerprint in fingerPrints.Split(';', StringSplitOptions.RemoveEmptyEntries))
- {
- if (!SSHFingerprint.TryParse(fingerprint, out var f))
- throw new ConfigException($"Invalid ssh fingerprint format {fingerprint}");
- SSHSettings?.TrustedFingerprints.Add(f);
- }
- }
-
RootPath = conf.GetOrDefault<string>("rootpath", "/");
if (!RootPath.StartsWith("/", StringComparison.InvariantCultureIgnoreCase))
RootPath = "/" + RootPath;
@@ -160,44 +116,8 @@ public void LoadArgs(IConfiguration conf, Logs Logs)
public bool CheatMode { get; set; }
- private SSHSettings ParseSSHConfiguration(IConfiguration conf)
- {
- var settings = new SSHSettings();
- settings.Server = conf.GetOrDefault<string>("sshconnection", null);
- if (settings.Server != null)
- {
- var parts = settings.Server.Split(':');
- if (parts.Length == 2 && int.TryParse(parts[1], out int port))
- {
- settings.Port = port;
- settings.Server = parts[0];
- }
- else
- {
- settings.Port = 22;
- }
-
- parts = settings.Server.Split('@');
- if (parts.Length == 2)
- {
- settings.Username = parts[0];
- settings.Server = parts[1];
- }
- else
- {
- settings.Username = "root";
- }
- }
- settings.Password = conf.GetOrDefault<string>("sshpassword", "");
- settings.KeyFile = conf.GetOrDefault<string>("sshkeyfile", "");
- settings.AuthorizedKeysFile = conf.GetOrDefault<string>("sshauthorizedkeys", "");
- settings.KeyFilePassword = conf.GetOrDefault<string>("sshkeyfilepassword", "");
- return settings;
- }
-
public string RootPath { get; set; }
public bool DockerDeployment { get; set; }
- public SSHSettings SSHSettings { get; set; }
public string TorrcFile { get; set; }
public string[] TorServices { get; set; }
public Uri UpdateUrl { get; set; }
### BTCPayServer/Configuration/DefaultConfiguration.cs
@@ -39,12 +39,6 @@ protected override CommandLineApplication CreateCommandLineApplicationCore()
app.Option("--deprecated", $"Allow deprecated settings (default:false)", CommandOptionType.BoolValue);
app.Option("--externalservices", $"Links added to external services inside Server Settings / Services under the format service1:path2;service2:path2.(default: empty)", CommandOptionType.SingleValue);
app.Option("--rootpath", "The root path in the URL to access BTCPay (default: /)", CommandOptionType.SingleValue);
- app.Option("--sshconnection", "SSH server to manage BTCPay under the form user@server:port (default: root@externalhost or empty)", CommandOptionType.SingleValue);
- app.Option("--sshpassword", "SSH password to manage BTCPay (default: empty)", CommandOptionType.SingleValue);
- app.Option("--sshkeyfile", "SSH private key file to manage BTCPay (default: empty)", CommandOptionType.SingleValue);
- app.Option("--sshkeyfilepassword", "Password of the SSH keyfile (default: empty)", CommandOptionType.SingleValue);
- app.Option("--sshauthorizedkeys", "Path to a authorized_keys file that BTCPayServer can modify from the website (default: empty)", CommandOptionType.SingleValue);
- app.Option("--sshtrustedfingerprints", "SSH Host public key fingerprint or sha256 (default: empty, it will allow untrusted connections)", CommandOptionType.SingleValue);
app.Option("--torrcfile", "Path to torrc file containing hidden services directories (default: empty)", CommandOptionType.SingleValue);
app.Option("--torservices", "Tor hostnames of available services added to Server Settings (and sets onion header for btcpay). Format: btcpayserver:host.onion:80;btc-p2p:host2.onion:81,BTC-RPC:host3.onion:82,UNKNOWN:host4.onion:83. (default: empty)", CommandOptionType.SingleValue);
app.Option("--socksendpoint", "Socks endpoint to connect to onion urls (default: empty)", CommandOptionType.SingleValue);
### BTCPayServer/Controllers/UIServerController.cs
@@ -1,11 +1,13 @@
#nullable enable
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
-using System.Net;
+using System.Text.Json;
+using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions;
using BTCPayServer.Abstractions.Constants;
@@ -14,12 +16,12 @@
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
-using BTCPayServer.Fido2;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Plugins.Emails.Services;
+using BTCPayServer.Plugins.Maintenance;
using BTCPayServer.Plugins.Monetization;
using BTCPayServer.Plugins.Translations;
using BTCPayServer.Services;
@@ -33,13 +35,11 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.Mvc.Rendering;
-using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Localization;
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
@@ -49,6 +49,7 @@ namespace BTCPayServer.Controllers
public partial class UIServerController : Controller
{
private readonly ISettingsAccessor<MonetizationSettings> _monetizationSettings;
+ private readonly ProcessRunner _processRunner;
private readonly UserManager<ApplicationUser> _UserManager;
private readonly UserService _userService;
readonly SettingsRepository _SettingsRepository;
@@ -59,7 +60,7 @@ public partial class UIServerController : Controller
private readonly TorServices _torServices;
private readonly BTCPayServerOptions _Options;
private readonly AppService _AppService;
- private readonly CheckConfigurationHostedService _sshState;
+ private readonly CheckHostCommandsHostedService _hostCommandState;
private readonly EventAggregator _eventAggregator;
private readonly IOptions<ExternalServicesOptions> _externalServiceOptions;
private readonly Logs Logs;
@@ -89,21 +90,20 @@ public UIServerController(
TorServices torServices,
StoreRepository storeRepository,
AppService appService,
- CheckConfigurationHostedService sshState,
+ CheckHostCommandsHostedService hostCommandState,
EventAggregator eventAggregator,
IOptions<ExternalServicesOptions> externalServiceOptions,
Logs logs,
CallbackGenerator callbackGenerator,
UriResolver uriResolver,
- IHostApplicationLifetime applicationLifetime,
IHtmlHelper html,
TransactionLinkProviders transactionLinkProviders,
LocalizerService localizer,
IStringLocalizer stringLocalizer,
ViewLocalizer viewLocalizer,
BTCPayServerEnvironment environment,
- LanguagePackUpdateService languagePackUpdateService,
- ISettingsAccessor<MonetizationSettings> monetizationSettings
+ ISettingsAccessor<MonetizationSettings> monetizationSettings,
+ ProcessRunner processRunner
)
{
_policiesSettings = policiesSettings;
@@ -119,17 +119,17 @@ ISettingsAccessor<MonetizationSettings> monetizationSettings
_LnConfigProvider = lnConfigProvider;
_torServices = torServices;
_AppService = appService;
- _sshState = sshState;
+ _hostCommandState = hostCommandState;
_eventAggregator = eventAggregator;
_externalServiceOptions = externalServiceOptions;
Logs = logs;
_emailSenderFactory = emailSenderFactory;
_callbackGenerator = callbackGenerator;
_uriResolver = uriResolver;
- ApplicationLifetime = applicationLifetime;
Html = html;
_transactionLinkProviders = transactionLinkProviders;
_monetizationSettings = monetizationSettings;
+ _processRunner = processRunner;
_localizer = localizer;
Environment = environment;
StringLocalizer = stringLocalizer;
@@ -156,189 +156,7 @@ public async Task<IActionResult> ListStores()
return View(vm);
}
- [HttpGet("server/maintenance")]
- public IActionResult Maintenance()
- {
- var vm = new MaintenanceViewModel
- {
- CanUseSSH = _sshState.CanUseSSH,
- DNSDomain = Request.Host.Host
- };
-
- if (!vm.CanUseSSH)
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Maintenance feature requires access to SSH properly configured in BTCPay Server configuration."].Value;
- if (IPAddress.TryParse(vm.DNSDomain, out var unused))
- vm.DNSDomain = null;
-
- return View(vm);
- }
-
- [HttpPost("server/maintenance")]
- public async Task<IActionResult> Maintenance(MaintenanceViewModel vm, string command)
- {
- vm.CanUseSSH = _sshState.CanUseSSH;
- if (command != "soft-restart" && !vm.CanUseSSH)
- {
- TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Maintenance feature requires access to SSH properly configured in BTCPay Server configuration."].Value;
- 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);
- }
- if (Uri.CheckHostName(vm.DNSDomain) != UriHostNameType.Dns)
- {
- ModelState.AddModelError(nameof(vm.DNSDomain), $"Invalid hostname");
- return View(vm);
- }
- var builder = new UriBuilder();
- 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<object>();
- 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] = StringLocalizer["Domain name changing... the server will restart, please use \"{0}\" (this page won't reload automatically)", builder.Uri.AbsoluteUri].Value;
- }
- else if (command == "update")
- {
- var error = await RunSSH(vm, $"btcpay-update.sh");
- if (error != null)
- return error;
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The server might restart soon if an update is available... (this page won't reload automatically)"].Value;
- }
- else if (command == "clean")
- {
- var error = await RunSSH(vm, $"btcpay-clean.sh");
- if (error != null)
- return error;
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The old docker images will be cleaned soon..."].Value;
- }
- else if (command == "restart")
- {
- var error = await RunSSH(vm, $"btcpay-restart.sh");
- if (error != null)
- return error;
- Logs.PayServer.LogInformation("A hard restart has been requested");
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["BTCPay will restart momentarily."].Value;
- }
- else if (command == "soft-restart")
- {
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["BTCPay will restart momentarily."].Value;
- Logs.PayServer.LogInformation("A soft restart has been requested");
- _ = Task.Delay(3000).ContinueWith((t) => ApplicationLifetime.StopApplication());
- }
- else
- {
- return NotFound();
- }
- return RedirectToAction(nameof(Maintenance));
- }
-
- private Task<IPAddress[]> 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<IActionResult?> 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 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 IHostApplicationLifetime ApplicationLifetime { get; }
+ static TimeSpan ShortOperation = TimeSpan.FromSeconds(10);
public IHtmlHelper Html { get; }
public BTCPayServerEnvironment Environment { get; }
@@ -821,49 +639,26 @@ public async Task<IActionResult> 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)
+ if (_hostCommandState.SupportedCommands.Contains(HostCommands.ShowAuthorizedKeys))
{
try
{
- using var sshClient = await _Options.SSHSettings.ConnectAsync();
- var result = await sshClient.RunBash("cat ~/.ssh/authorized_keys", TimeSpan.FromSeconds(10));
- vm.SSHKeyFileContent = result.Output;
+ var result = await _processRunner.RunHostCommand(HostCommands.ShowAuthorizedKeys, null, TimeSpan.FromSeconds(10));
+ if (result.ExitCode == 0)
+ {
+ vm.SSHKeyFileContent = JsonSerializer.Deserialize<string>(result.Output) ?? string.Empty;
+ }
}
catch { }
}
return View(vm);
}
bool CanShowSSHService()
- {
- return !_policiesSettings.DisableSSHService &&
- _Options.SSHSettings != null && (_sshState.CanUseSSH || CanAccessAuthorizedKeyFile());
- }
-
- private bool CanAccessAuthorizedKeyFile()
- {
- return _Options.SSHSettings?.AuthorizedKeysFile != null && System.IO.File.Exists(_Options.SSHSettings.AuthorizedKeysFile);
- }
+ => _hostCommandState.SupportedCommands.Contains(HostCommands.ShowAuthorizedKeys) &&
+ _hostCommandState.SupportedCommands.Contains(HostCommands.SetAuthorizedKeys);
[HttpPost("server/services/ssh")]
public async Task<IActionResult> SSHService(SSHServiceViewModel viewModel, string? command = null)
@@ -876,39 +671,18 @@ public async Task<IActionResult> SSHService(SSHServiceViewModel viewModel, strin
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
{
- try
- {
- await System.IO.File.WriteAllTextAsync(_Options.SSHSettings.AuthorizedKeysFile, newContent);
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["authorized_keys has been updated"].Value;
- updated = true;
- }
- catch (Exception ex)
+ var result = await _processRunner.RunHostCommand(HostCommands.SetAuthorizedKeys, [newContent], ShortOperation);
+ if (result.ExitCode != 0)
{
- exception = ex;
+ throw new InvalidOperationException(string.IsNullOrEmpty(result.Error) ? $"{HostCommands.SetAuthorizedKeys} failed with exit status {result.ExitCode}" : result.Error);
}
}
-
- // If that fail, fallback to ssh
- if (!updated && _sshState.CanUseSSH)
+ catch (Exception ex)
{
- 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;
- }
+ exception = ex;
}
if (exception is null)
@@ -922,30 +696,9 @@ public async Task<IActionResult> SSHService(SSHServiceViewModel viewModel, strin
return RedirectToAction(nameof(SSHService));
}
- if (command is "disable")
- {
- return RedirectToAction(nameof(SSHServiceDisable));
- }
-
return NotFound();
}
- [HttpGet("server/services/ssh/disable")]
- public IActionResult SSHServiceDisable()
- {
- return View("Confirm", new ConfirmModel(StringLocalizer["Disable modification of SSH settings"], StringLocalizer["This action is permanent and will remove the ability to change the SSH settings via the BTCPay Server user interface."], StringLocalizer["Disable"]));
- }
-
- [HttpPost("server/services/ssh/disable")]
- public async Task<IActionResult> SSHServiceDisablePost()
- {
- var policies = await _SettingsRepository.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
- policies.DisableSSHService = true;
- await _SettingsRepository.UpdateSetting(policies);
- TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Changes to the SSH settings are now permanently disabled in the BTCPay Server user interface"].Value;
- return RedirectToAction(nameof(Services));
- }
-
[HttpGet("server/branding")]
public async Task<IActionResult> Branding()
{
### BTCPayServer/Extensions/SSHClientExtensions.cs
@@ -1,130 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.SSH;
-using Renci.SshNet;
-
-namespace BTCPayServer
-{
- public static class SSHClientExtensions
- {
- public static async Task<SshClient> ConnectAsync(this SSHSettings sshSettings, CancellationToken cancellationToken = default)
- {
- ArgumentNullException.ThrowIfNull(sshSettings);
- TaskCompletionSource<SshClient> tcs = new TaskCompletionSource<SshClient>(TaskCreationOptions.RunContinuationsAsynchronously);
- new Thread(() =>
- {
- SshClient sshClient = null;
- try
- {
- 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);
- }
- };
- sshClient.Connect();
- tcs.TrySetResult(sshClient);
- }
- catch (Exception ex)
- {
- tcs.TrySetException(ex);
- try
- {
- sshClient?.Dispose();
- }
- catch { }
- }
- })
- { IsBackground = true }.Start();
-
- using (cancellationToken.Register(() => { tcs.TrySetCanceled(); }))
- {
- return await tcs.Task;
- }
- }
-
- public static string EscapeSingleQuotes(this string command)
- {
- return command.Replace("'", "'\"'\"'", StringComparison.OrdinalIgnoreCase);
- }
-
- public static Task<SSHCommandResult> RunBash(this SshClient sshClient, string command, TimeSpan? timeout = null)
- {
- ArgumentNullException.ThrowIfNull(sshClient);
- ArgumentNullException.ThrowIfNull(command);
- command = $"bash -c '{command.EscapeSingleQuotes()}'";
- var sshCommand = sshClient.CreateCommand(command);
- if (timeout is TimeSpan v)
- sshCommand.CommandTimeout = v;
- var tcs = new TaskCompletionSource<SSHCommandResult>(TaskCreationOptions.RunContinuationsAsynchronously);
- new Thread(() =>
- {
- try
- {
- sshCommand.BeginExecute(ar =>
- {
- try
- {
- sshCommand.EndExecute(ar);
- tcs.TrySetResult(CreateSSHCommandResult(sshCommand));
- }
- catch (Exception ex)
- {
- tcs.TrySetException(ex);
- }
- finally
- {
- sshCommand.Dispose();
- }
- });
- }
- catch (Exception ex) { tcs.TrySetException(ex); }
- })
- { IsBackground = true }.Start();
- return tcs.Task;
- }
-
- private static SSHCommandResult CreateSSHCommandResult(SshCommand sshCommand)
- {
- if (sshCommand.ExitStatus is null)
- throw new InvalidOperationException("ExitStatus is null");
- return new SSHCommandResult()
- {
- Output = sshCommand.Result,
- Error = sshCommand.Error,
- ExitStatus = sshCommand.ExitStatus.Value
- };
- }
-
- public static async Task DisconnectAsync(this SshClient sshClient, CancellationToken cancellationToken = default)
- {
- ArgumentNullException.ThrowIfNull(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();
- using (cancellationToken.Register(() => tcs.TrySetCanceled()))
- {
- await tcs.Task;
- }
- }
- }
-}
### BTCPayServer/HostCommands.cs
@@ -0,0 +1,33 @@
+namespace BTCPayServer;
+
+public static class HostCommands
+{
+ /// <summary>
+ /// Lists the host commands supported by the current deployment.
+ /// </summary>
+ public const string Commands = "commands";
+ /// <summary>
+ /// Returns the current authorized SSH public keys so they can be displayed in the server UI.
+ /// </summary>
+ public const string ShowAuthorizedKeys = "showauthorizedkeys";
+ /// <summary>
+ /// Replaces the authorized SSH public keys with the content provided by BTCPay Server.
+ /// </summary>
+ public const string SetAuthorizedKeys = "setauthorizedkeys";
+ /// <summary>
+ /// Starts the host-side domain change process for the BTCPay Server instance.
+ /// </summary>
+ public const string ChangeDomain = "changedomain";
+ /// <summary>
+ /// Starts the host-side update process for BTCPay Server.
+ /// </summary>
+ public const string Update = "update";
+ /// <summary>
+ /// Starts cleanup of unused host resources, such as old Docker images.
+ /// </summary>
+ public const string Clean = "clean";
+ /// <summary>
+ /// Starts a host-side restart of BTCPay Server and related services.
+ /// </summary>
+ public const string Restart = "restart";
+}
### BTCPayServer/HostedServices/CheckConfigurationHostedService.cs
@@ -1,86 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Configuration;
-using BTCPayServer.Logging;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-
-namespace BTCPayServer.HostedServices
-{
- public class CheckConfigurationHostedService : IHostedService
- {
- public Logs Logs { get; }
-
- private readonly BTCPayServerOptions _options;
- Task _testingConnection;
- readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
-
- public CheckConfigurationHostedService(BTCPayServerOptions options, Logs logs)
- {
- Logs = logs;
- _options = options;
- }
-
- public bool CanUseSSH { get; private set; }
-
- public Task StartAsync(CancellationToken cancellationToken)
- {
- _testingConnection = TestConnection();
- return Task.CompletedTask;
- }
-
- async Task TestConnection()
- {
- TimeSpan nextWait = TimeSpan.FromSeconds(10);
-retry:
- var canUseSSH = false;
- 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
- {
- using var connection = await _options.SSHSettings.ConnectAsync(_cancellationTokenSource.Token);
- await connection.DisconnectAsync(_cancellationTokenSource.Token);
- Logs.Configuration.LogInformation($"SSH connection succeeded");
- canUseSSH = true;
- }
- catch (Renci.SshNet.Common.SshAuthenticationException ex)
- {
- Logs.Configuration.LogWarning($"SSH invalid credentials ({ex.Message})");
- }
- 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}");
- }
- if (!canUseSSH)
- {
- Logs.Configuration.LogWarning($"Retrying SSH connection in {(int)nextWait.TotalSeconds} seconds");
- await Task.Delay(nextWait, _cancellationTokenSource.Token);
- nextWait = TimeSpan.FromSeconds(nextWait.TotalSeconds * 2);
- if (nextWait > TimeSpan.FromMinutes(10.0))
- nextWait = TimeSpan.FromMinutes(10.0);
- goto retry;
- }
- }
- CanUseSSH = canUseSSH;
- }
-
- public async Task StopAsync(CancellationToken cancellationToken)
- {
- _cancellationTokenSource.Cancel();
- try
- {
- // Renci SSH sometimes is deadlocking, so we just wait at most 5 seconds
- await Task.WhenAny(_testingConnection, Task.Delay(5000, _cancellationTokenSource.Token));
- }
- catch { }
- Logs.PayServer.LogInformation($"{this.GetType().Name} successfully exited...");
- }
- }
-}
### BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -366,8 +366,7 @@ public static IServiceCollection AddBTCPayServer(this IServiceCollection service
services.AddSingleton<Services.NBXplorerConnectionFactory>();
services.AddSingleton<IHostedService, Services.NBXplorerConnectionFactory>(o => o.GetRequiredService<Services.NBXplorerConnectionFactory>());
- services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
- services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<CheckConfigurationHostedService>());
+ services.AddSingleton<Services.ProcessRunner>();
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
services.AddScheduledTask<GithubVersionFetcher>(TimeSpan.FromDays(1));
### BTCPayServer/Models/ServerViewModels/SSHServiceViewModel.cs
@@ -2,10 +2,6 @@ namespace BTCPayServer.Models.ServerViewModels
{
public class SSHServiceViewModel
{
- public string CommandLine { get; set; }
- public string Password { get; set; }
- public string KeyFilePassword { get; set; }
- public bool HasKeyFile { get; set; }
public string SSHKeyFileContent { get; set; }
}
}
### BTCPayServer/Plugins/GlobalSearch/GlobalSearchPlugin.cs
@@ -170,24 +170,6 @@ private static void AddDefaultStaticSearch(IServiceCollection services)
Keywords = ["Server", "Settings", "Branding", "Configure"]
},
new ActionResultItemViewModel
- {
- RequiredPolicy = Policies.CanModifyServerSettings,
- Title = "Go to the maintenance page",
- Action = nameof(UIServerController.Maintenance),
- Controller = "UIServer",
- Category = "Server",
- Keywords = ["Server", "Settings", "Maintenance"]
- },
- new ActionResultItemViewModel
- {
- RequiredPolicy = Policies.CanModifyServerSettings,
- Title = "Update the server",
- Action = nameof(UIServerController.Maintenance),
- Controller = "UIServer",
- Category = "Server",
- Keywords = ["Server", "Settings", "Maintenance"]
- },
- new ActionResultItemViewModel
{
RequiredPolicy = Policies.CanModifyServerSettings,
Title = "View the logs",
### BTCPayServer/Plugins/Maintenance/CheckHostCommandsHostedService.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Logging;
+using BTCPayServer.Services;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace BTCPayServer.Plugins.Maintenance
+{
+ public class CheckHostCommandsHostedService : IHostedService
+ {
+ public Logs Logs { get; }
+
+ private readonly ProcessRunner _processRunner;
+ Task _testingConnection;
+ readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
+
+ public CheckHostCommandsHostedService(ProcessRunner processRunner, Logs logs)
+ {
+ Logs = logs;
+ _processRunner = processRunner;
+ }
+
+ public HashSet<string> SupportedCommands { get; private set; } = new HashSet<string>();
+ public bool BTCPayHostAvailable { get; private set; }
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _testingConnection = TestConnection();
+ return Task.CompletedTask;
+ }
+
+ async Task TestConnection()
+ {
+ var supportedCommands = new HashSet<string>();
+ try
+ {
+ var commands = await _processRunner.RunHostCommand(HostCommands.Commands, null, _cancellationTokenSource.Token);
+ if (commands.ExitCode == 0)
+ {
+ var parsedCommands = JsonSerializer.Deserialize<string[]>(commands.Output) ?? [];
+ foreach (var command in parsedCommands)
+ {
+ if (!string.IsNullOrWhiteSpace(command))
+ supportedCommands.Add(command.Trim());
+ }
+ BTCPayHostAvailable = true;
+ Logs.PayServer.LogInformation("Supported host commands: {commands}", string.Join(", ", supportedCommands));
+ }
+ else
+ {
+ Logs.PayServer.LogInformation($"Call to 'btcpay-host commands' failed ({commands.Error})");
+ }
+ }
+ catch
+ {
+ Logs.PayServer.LogInformation("btcpay-host not supported by the host");
+ }
+ SupportedCommands = supportedCommands;
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ _cancellationTokenSource.Cancel();
+ try
+ {
+ // Command checks run in the background, so we just wait at most 5 seconds
+ await Task.WhenAny(_testingConnection, Task.Delay(5000, _cancellationTokenSource.Token));
+ }
+ catch { }
+ Logs.PayServer.LogInformation($"{this.GetType().Name} successfully exited...");
+ }
+ }
+}
### BTCPayServer/Plugins/Maintenance/Controllers/UIMaintenanceController.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Client;
+using BTCPayServer.Logging;
+using BTCPayServer.Plugins.Maintenance.Models;
+using BTCPayServer.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Localization;
+using Microsoft.Extensions.Logging;
+using AuthenticationSchemes = BTCPayServer.Abstractions.Constants.AuthenticationSchemes;
+
+namespace BTCPayServer.Plugins.Maintenance.Controllers;
+
+[Authorize(Policy = Policies.CanModifyServerSettings,
+ AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[Area(MaintenancePlugin.Area)]
+public class UIMaintenanceController(
+ CheckHostCommandsHostedService hostCommandState,
+ ProcessRunner processRunner,
+ IHostApplicationLifetime applicationLifetime,
+ Logs logs,
+ IStringLocalizer stringLocalizer) : Controller
+{
+ private static readonly TimeSpan LongOperation = TimeSpan.FromMinutes(20);
+
+ [HttpGet("server/maintenance")]
+ public IActionResult Maintenance()
+ {
+ if (!hostCommandState.BTCPayHostAvailable)
+ return NotFound();
+
+ var vm = new MaintenanceViewModel
+ {
+ SupportedCommands = hostCommandState.SupportedCommands,
+ DNSDomain = Request.Host.Host
+ };
+
+ if (IPAddress.TryParse(vm.DNSDomain, out var unused))
+ vm.DNSDomain = null;
+
+ return View("/Plugins/Maintenance/Views/Maintenance.cshtml", vm);
+ }
+
+ [HttpPost("server/maintenance")]
+ public async Task<IActionResult> Maintenance(MaintenanceViewModel vm, string command)
+ {
+ vm.SupportedCommands = hostCommandState.SupportedCommands;
+ if (command != "soft-restart" && !hostCommandState.BTCPayHostAvailable)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = stringLocalizer["Maintenance feature requires local BTCPay commands."].Value;
+ return View("/Plugins/Maintenance/Views/Maintenance.cshtml", vm);
+ }
+ if (!ModelState.IsValid)
+ return View("/Plugins/Maintenance/Views/Maintenance.cshtml", vm);
+
+ if (command == "changedomain" && vm.SupportedCommands.Contains(HostCommands.ChangeDomain))
+ {
+ if (string.IsNullOrWhiteSpace(vm.DNSDomain))
+ {
+ ModelState.AddModelError(nameof(vm.DNSDomain), $"Required field");
+ return View("/Plugins/Maintenance/Views/Maintenance.cshtml", vm);
+ }
+ vm.DNSDomain = vm.DNSDomain.Trim().ToLowerInvariant();
+ if (vm.DNSDomain.Equals(Request.Host.Host, StringComparison.OrdinalIgnoreCase))
+ return View("/Plugins/Maintenance/Views/Maintenance.cshtml", vm);
+ if (IPAddress.TryParse(vm.DNSDomain, out var unused))
+ {
+ ModelState.AddModelError(nameof(vm.DNSDomain), $"This should be a domain name");
+ return View("/Plugins/Maintenance/Views/Maintenance.cshtml", vm);
+ }
+ if (vm.DNSDomain.Equals(Request.Host.Host, StringComparison.InvariantCultureIgnoreCase))
+ {
+ ModelState.AddModelError(nameof(vm.DNSDomain), $"The server is already set to use this domain");
+ return View("/Plugins/Maintenance/Views/Maintenance.cshtml", vm);
+ }
+ if (Uri.CheckHostName(vm.DNSDomain) != UriHostNameType.Dns)
+ {
+ ModelState.AddModelError(nameof(vm.DNSDomain), $"Invalid hostname");
+ return View("/Plugins/Maintenance/Views/Maintenance.cshtml", vm);
+ }
+ var builder = new UriBuilder();
+ try
+ {
+ builder.Scheme = Request.Scheme;
+ builder.Host = vm.DNSDomain;
+ var addresses1 = GetAddressAsync(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("/Plugins/Maintenance/Views/Maintenance.cshtml", vm);
+ }
+ }
+ catch (Exception ex)
+ {
+ var messages = new List<object>();
+ 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("/Plugins/Maintenance/Views/Maintenance.cshtml", vm);
+ }
+
+ _ = processRunner.RunHostCommand(HostCommands.ChangeDomain, new[] { vm.DNSDomain }, TimeSpan.FromMinutes(20));
+ builder.Path = null;
+ builder.Query = null;
+ TempData[WellKnownTempData.SuccessMessage] = stringLocalizer["Domain name changing... the server will restart, please use \"{0}\" (this page won't reload automatically)", builder.Uri.AbsoluteUri].Value;
+ }
+ else if (command == "update" && vm.SupportedCommands.Contains(HostCommands.Update))
+ {
+ _ = processRunner.RunHostCommand(HostCommands.Update, null, LongOperation);
+ TempData[WellKnownTempData.SuccessMessage] = stringLocalizer["The server might restart soon if an update is available... (this page won't reload automatically)"].Value;
+ }
+ else if (command == "clean" && vm.SupportedCommands.Contains(HostCommands.Clean))
+ {
+ _ = processRunner.RunHostCommand(HostCommands.Clean, null, LongOperation);
+ TempData[WellKnownTempData.SuccessMessage] = stringLocalizer["The old docker images will be cleaned soon..."].Value;
+ }
+ else if (command == "restart" && vm.SupportedCommands.Contains(HostCommands.Restart))
+ {
+ _ = processRunner.RunHostCommand(HostCommands.Restart, null, LongOperation);
+ logs.PayServer.LogInformation("A hard restart has been requested");
+ TempData[WellKnownTempData.SuccessMessage] = stringLocalizer["BTCPay will restart momentarily."].Value;
+ }
+ else if (command == "soft-restart")
+ {
+ TempData[WellKnownTempData.SuccessMessage] = stringLocalizer["BTCPay will restart momentarily."].Value;
+ logs.PayServer.LogInformation("A soft restart has been requested");
+ _ = Task.Delay(3000).ContinueWith((t) => applicationLifetime.StopApplication());
+ }
+ else
+ {
+ return NotFound();
+ }
+ return RedirectToAction(nameof(Maintenance));
+ }
+
+ private Task<IPAddress[]> GetAddressAsync(string domainOrIP)
+ {
+ return IPAddress.TryParse(domainOrIP, out var ip) ? Task.FromResult(new[] { ip }) : Dns.GetHostAddressesAsync(domainOrIP);
+ }
+}
### BTCPayServer/Plugins/Maintenance/MaintenancePlugin.cs
@@ -0,0 +1,22 @@
+using BTCPayServer.Abstractions.Models;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace BTCPayServer.Plugins.Maintenance;
+
+public class MaintenancePlugin : BaseBTCPayServerPlugin
+{
+ public const string Area = "Maintenance";
+ public override string Identifier => "BTCPayServer.Plugins.Maintenance";
+ public override string Name => "Maintenance";
+ public override string Description => "Manage BTCPay Server maintenance actions.";
+
+ public override void Execute(IServiceCollection services)
+ {
+ services.AddSingleton<CheckHostCommandsHostedService>();
+ services.AddSingleton<IHostedService, CheckHostCommandsHostedService>(o => o.GetRequiredService<CheckHostCommandsHostedService>());
+ services.AddUIExtension("server-nav", "/Plugins/Maintenance/Views/NavExtension.cshtml");
+ services.AddSearchResultItemProvider<MaintenanceSearchResultProvider>();
+ services.AddTranslationProvider<MaintenanceSearchResultProvider.TranslationProvider>();
+ }
+}
### BTCPayServer/Plugins/Maintenance/MaintenanceSearchResultProvider.cs
@@ -0,0 +1,58 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Client;
+using BTCPayServer.Plugins.GlobalSearch;
+using BTCPayServer.Plugins.GlobalSearch.Views;
+using BTCPayServer.Plugins.Maintenance.Controllers;
+using BTCPayServer.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Plugins.Maintenance;
+
+public class MaintenanceSearchResultProvider(CheckHostCommandsHostedService hostCommands) : ISearchResultItemProvider
+{
+ private static readonly string[] Keywords = ["Server", "Settings", "Maintenance"];
+
+ public Task ProvideAsync(SearchResultItemProviderContext context, CancellationToken cancellationToken)
+ {
+ if (context.UserQuery is not null || !hostCommands.BTCPayHostAvailable)
+ return Task.CompletedTask;
+
+ context.ItemResults.Add(new ResultItemViewModel
+ {
+ RequiredPolicy = Policies.CanModifyServerSettings,
+ Title = "Go to the maintenance page",
+ Url = context.Url.Action(nameof(UIMaintenanceController.Maintenance), "UIMaintenance", new { area = MaintenancePlugin.Area }),
+ Category = "Server",
+ Keywords = Keywords
+ });
+ if (hostCommands.SupportedCommands.Contains(HostCommands.Update))
+ {
+ context.ItemResults.Add(new ResultItemViewModel
+ {
+ RequiredPolicy = Policies.CanModifyServerSettings,
+ Title = "Update the server",
+ Url = context.Url.Action(nameof(UIMaintenanceController.Maintenance), "UIMaintenance", new { area = MaintenancePlugin.Area }),
+ Category = "Server",
+ Keywords = Keywords
+ });
+ }
+ return Task.CompletedTask;
+ }
+
+ internal class TranslationProvider : IDefaultTranslationProvider
+ {
+ public Task<KeyValuePair<string, string?>[]> GetDefaultTranslations()
+ {
+ return Task.FromResult<KeyValuePair<string, string?>[]>([
+ KeyValuePair.Create("Go to the maintenance page", null as string),
+ KeyValuePair.Create("Update the server", null as string),
+ KeyValuePair.Create("Server", null as string),
+ KeyValuePair.Create("Settings", null as string),
+ KeyValuePair.Create("Maintenance", null as string)
+ ]);
+ }
+ }
+}
### BTCPayServer/Plugins/Maintenance/Models/MaintenanceViewModel.cs
@@ -1,10 +1,11 @@
+using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
-namespace BTCPayServer.Models.ServerViewModels;
+namespace BTCPayServer.Plugins.Maintenance.Models;
public class MaintenanceViewModel
{
[Display(Name = "Domain name")]
public string DNSDomain { get; set; }
- public bool CanUseSSH { get; internal set; }
+ public HashSet<string> SupportedCommands { get; set; }
}
### BTCPayServer/Plugins/Maintenance/Views/Maintenance.cshtml
@@ -1,7 +1,8 @@
@using Microsoft.AspNetCore.Html
-@model BTCPayServer.Models.ServerViewModels.MaintenanceViewModel
+@using BTCPayServer.Views.Server
+@model MaintenanceViewModel
@{
- ViewData.SetLayoutModel(new LayoutModel(nameof(ServerNavPages.Maintenance), StringLocalizer["Maintenance"])
+ ViewData.SetLayoutModel(new LayoutModel(nameof(MaintenancePlugin), StringLocalizer["Maintenance"])
.SetCategory(WellKnownCategories.Server));
}
@@ -15,33 +16,33 @@
<div class="col-xl-8 col-xxl-constrain">
<div class="form-group">
<label asp-for="DNSDomain" class="form-label"></label>
- <input asp-for="DNSDomain" class="form-control" disabled="@(Model.CanUseSSH ? null : "disabled")" />
+ <input asp-for="DNSDomain" class="form-control" disabled="@(Model.SupportedCommands.Contains(HostCommands.ChangeDomain) ? null : "disabled")" />
<div class="form-text">@ViewLocalizer["You can change the domain name of your server by following {0}.", new HtmlString($"<a href=\"https://docs.btcpayserver.org/FAQ/Deployment/#how-to-change-your-btcpay-server-domain-name\" target=\"_blank\" rel=\"noreferrer noopener\">{StringLocalizer["this guide"]}</a>")]</div>
<span asp-validation-for="DNSDomain" class="text-danger"></span>
</div>
- <button name="command" type="submit" class="btn btn-secondary" value="changedomain" title="@StringLocalizer["Change domain"]" disabled="@(Model.CanUseSSH ? null : "disabled")">Change Domain</button>
+ <button name="command" type="submit" class="btn btn-secondary" value="changedomain" title="@StringLocalizer["Change domain"]" disabled="@(Model.SupportedCommands.Contains(HostCommands.ChangeDomain) ? null : "disabled")">Change Domain</button>
<h4 class="mt-5 mb-2" text-translate="true">Restart</h4>
<p text-translate="true">Restart BTCPay Server and related services.</p>
<div class="form-group">
<div class="input-group">
- <button name="command" type="submit" class="btn btn-secondary" value="restart" disabled="@(Model.CanUseSSH ? null : "disabled")" text-translate="true">Restart</button>
+ <button name="command" type="submit" class="btn btn-secondary" value="restart" disabled="@(Model.SupportedCommands.Contains(HostCommands.Restart) ? null : "disabled")" text-translate="true">Restart</button>
</div>
</div>
<h4 class="mt-5 mb-2" text-translate="true">Clean</h4>
<p text-translate="true">Delete unused Docker images present on your system.</p>
<div class="form-group">
<div class="input-group">
- <button name="command" type="submit" class="btn btn-secondary" value="clean" disabled="@(Model.CanUseSSH ? null : "disabled")" text-translate="true">Clean</button>
+ <button name="command" type="submit" class="btn btn-secondary" value="clean" disabled="@(Model.SupportedCommands.Contains(HostCommands.Clean) ? null : "disabled")" text-translate="true">Clean</button>
</div>
</div>
<h4 class="mt-5 mb-2" text-translate="true">Update</h4>
<p text-translate="true">Update to the latest version of BTCPay Server.</p>
<div class="form-group">
<div class="input-group">
- <button name="command" type="submit" class="btn btn-primary" value="update" disabled="@(Model.CanUseSSH ? null : "disabled")" text-translate="true">Update</button>
+ <button name="command" type="submit" class="btn btn-primary" value="update" disabled="@(Model.SupportedCommands.Contains(HostCommands.Update) ? null : "disabled")" text-translate="true">Update</button>
</div>
</div>
</div>
### BTCPayServer/Plugins/Maintenance/Views/NavExtension.cshtml
@@ -0,0 +1,15 @@
+@model BTCPayServer.Components.MainNav.MainNavViewModel
+@inject CheckHostCommandsHostedService HostCommands
+
+@if (HostCommands.BTCPayHostAvailable)
+{
+ <li permission="@Policies.CanModifyServerSettings">
+ <a
+ id="menu-item-@nameof(MaintenancePlugin)"
+ class="dropdown-item@(ViewData["ActivePage"]?.ToString() == nameof(MaintenancePlugin) ? " active" : "")"
+ asp-area="@MaintenancePlugin.Area"
+ asp-controller="UIMaintenance"
+ asp-action="Maintenance"
+ text-translate="true">Maintenance</a>
+ </li>
+}
### BTCPayServer/Plugins/Maintenance/Views/_ViewImports.cshtml
@@ -0,0 +1,5 @@
+@using BTCPayServer.Client
+@using BTCPayServer.Plugins.Maintenance
+@using BTCPayServer.Plugins.Maintenance.Models
+@using BTCPayServer.Views.Server
+@namespace BTCPayServer.Plugins.Maintenance.Views
### BTCPayServer/Plugins/Maintenance/Views/_ViewStart.cshtml
@@ -0,0 +1,3 @@
+@{
+ Layout = "_Layout";
+}
### BTCPayServer/Plugins/PluginManager/Views/ListPlugins.cshtml
@@ -14,7 +14,7 @@
{
<div class="alert alert-info mb-4 d-flex align-items-center justify-content-between">
<span text-translate="true">You need to restart BTCPay Server in order to update your active plugins.</span>
- <form method="post" asp-area="" asp-controller="UIServer" asp-action="Maintenance" class="mt-2">
+ <form method="post" asp-area="@MaintenancePlugin.Area" asp-controller="UIMaintenance" asp-action="Maintenance" class="mt-2">
<button type="submit" name="command" value="soft-restart" class="btn btn-info" text-translate="true">Restart now</button>
</form>
</div>
### BTCPayServer/Plugins/PluginManager/Views/PluginDirectory.cshtml
@@ -37,7 +37,7 @@
{
<div class="alert alert-info mb-4 d-flex align-items-center justify-content-between">
<span text-translate="true">You need to restart BTCPay Server in order to update your active plugins.</span>
- <form method="post" asp-area="" asp-controller="UIServer" asp-action="Maintenance" class="mt-2">
+ <form method="post" asp-area="@MaintenancePlugin.Area" asp-controller="UIMaintenance" asp-action="Maintenance" class="mt-2">
<button type="submit" name="command" value="soft-restart" class="btn btn-info" text-translate="true">Restart now</button>
</form>
</div>
### BTCPayServer/Plugins/PluginManager/Views/_ViewImports.cshtml
@@ -1,2 +1,3 @@
@using BTCPayServer.Plugins.PluginManagement.Models
+@using BTCPayServer.Plugins.Maintenance
@namespace BTCPayServer.Plugins.PluginManagement.Views
### BTCPayServer/Plugins/Translations/Translations.Default.cs
@@ -319,7 +319,6 @@ static Translations()
"Change Storage provider": "",
"Change your {0} provider.": "",
"Change your password": "",
- "Changes to the SSH settings are now permanently disabled in the BTCPay Server user interface": "",
"Changing the role of user {0} failed: {1}": "",
"Charge": "",
"Charge user": "",
@@ -578,7 +577,6 @@ static Translations()
"Disable admin": "",
"Disable all notifications": "",
"Disable Authenticator": "",
- "Disable modification of SSH settings": "",
"Disable payment button": "",
"Disable public user registration": "",
"Disable stores from using the server's email settings as backup": "",
@@ -888,7 +886,6 @@ static Translations()
"Inclusion in the list of BTCPay Server plugins does not constitute an endorsement or guarantee of quality, safety, or compatibility.": "",
"Incoming": "",
"Incorrect pin code.": "",
- "Increase the security of your instance by disabling the ability to change the SSH settings in this BTCPay Server instance's user interface.": "",
"Index": "",
"Input the key string manually": "",
"Inputs": "",
@@ -1979,7 +1976,6 @@ static Translations()
"There isn't any UTXO available to bump fee with CPFP": "",
"There was an error generating your wallet: {0}": "",
"This account has been locked out. Please try again": "",
- "This action is permanent and will remove the ability to change the SSH settings via the BTCPay Server user interface.": "",
"This action will delete your rate script. Are you sure to turn off rate rules scripting?": "",
"This action will modify your current rate sources. Are you sure to turn on rate rules scripting? (Advanced users)": "",
"This action will permanently delete your LND seed and password. You will not be able to recover them if you don't have a backup.": "",
### BTCPayServer/SSH/SSHCommandResult.cs
@@ -1,9 +0,0 @@
-namespace BTCPayServer.SSH
-{
- public class SSHCommandResult
- {
- public int ExitStatus { get; internal set; }
- public string Output { get; internal set; }
- public string Error { get; internal set; }
- }
-}
### BTCPayServer/SSH/SSHFingerprint.cs
@@ -1,92 +0,0 @@
-using System;
-using System.Globalization;
-using System.Linq;
-using NBitcoin;
-using NBitcoin.DataEncoders;
-
-namespace BTCPayServer.SSH
-{
- public class SSHFingerprint
- {
- public static bool TryParse(string str, out SSHFingerprint fingerPrint)
- {
- ArgumentNullException.ThrowIfNull(str);
- fingerPrint = null;
- str = str.Trim();
- try
- {
- var shortFingerprint = str.Replace(":", "", StringComparison.OrdinalIgnoreCase);
- if (HexEncoder.IsWellFormed(shortFingerprint))
- {
- var hash = Encoders.Hex.DecodeData(shortFingerprint);
- if (hash.Length == 16)
- {
- fingerPrint = new SSHFingerprint(hash);
- return true;
- }
- return false;
- }
- }
- catch
- {
- }
-
- if (str.StartsWith("SHA256:", StringComparison.OrdinalIgnoreCase))
- str = str.Substring("SHA256:".Length).Trim();
- if (str.Contains(':', StringComparison.OrdinalIgnoreCase))
- return false;
- if (!str.EndsWith('='))
- str = str + "=";
- try
- {
- var hash = Encoders.Base64.DecodeData(str);
- if (hash.Length == 32)
- {
- fingerPrint = new SSHFingerprint(hash);
- return true;
- }
- }
- catch
- {
- }
- return false;
- }
-
- public SSHFingerprint(byte[] hash)
- {
- if (hash.Length == 16)
- {
- _ShortFingerprint = hash;
- _Original = string.Join(':', hash.Select(b => b.ToString("x2", CultureInfo.InvariantCulture))
- .ToArray());
- }
- else if (hash.Length == 32)
- {
- _FullHash = hash;
- _Original = "SHA256:" + Encoders.Base64.EncodeData(hash);
- if (_Original.EndsWith("=", StringComparison.OrdinalIgnoreCase))
- _Original = _Original.Substring(0, _Original.Length - 1);
- }
- else
- throw new ArgumentException(paramName: nameof(hash), message: "Invalid length, expected 16 or 32");
- }
-
- readonly byte[] _ShortFingerprint;
- readonly byte[] _FullHash;
-
- public bool Match(byte[] shortFingerprint, byte[] hostKey)
- {
- ArgumentNullException.ThrowIfNull(shortFingerprint);
- ArgumentNullException.ThrowIfNull(hostKey);
- if (_ShortFingerprint != null)
- return Utils.ArrayEqual(shortFingerprint, _ShortFingerprint);
- return Utils.ArrayEqual(_FullHash, NBitcoin.Crypto.Hashes.SHA256(hostKey));
- }
-
- readonly string _Original;
- public override string ToString()
- {
- return _Original;
- }
- }
-}
### BTCPayServer/SSH/SSHSettings.cs
@@ -1,34 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using Renci.SshNet;
-
-namespace BTCPayServer.SSH
-{
- public class SSHSettings
- {
- public string Server { get; set; }
- public int Port { get; set; } = 22;
- public string KeyFile { get; set; }
- public string KeyFilePassword { get; set; }
- public string AuthorizedKeysFile { 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()
- {
- if (!string.IsNullOrEmpty(KeyFile))
- {
- return new ConnectionInfo(Server, Port, Username, new[] { new PrivateKeyAuthenticationMethod(Username, new PrivateKeyFile(KeyFile, KeyFilePassword)) });
- }
- else
- {
- return new ConnectionInfo(Server, Port, Username, new[] { new PasswordAuthenticationMethod(Username, Password) });
- }
- }
- }
-}
### BTCPayServer/Services/PoliciesSettings.cs
@@ -81,8 +81,6 @@ public bool EnableNonAdminCreateUserApi
[Display(Name = "Show plugins in pre-release")]
public bool PluginPreReleases { get; set; }
- public bool DisableSSHService { get; set; }
-
[Display(Name = "Display app on website root")]
public string RootAppId { get; set; }
public string RootAppType { get; set; }
### BTCPayServer/Services/ProcessRunner.cs
@@ -0,0 +1,246 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+// COPIED FROM https://github.com/dotnet/sdk/blob/main/src/BuiltInTools/dotnet-watch/
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace BTCPayServer.Services;
+
+public sealed record HostCommandResult(int ExitCode, string Output, string Error);
+public class ProcessRunner(ILoggerFactory loggerFactory, IConfiguration conf)
+{
+ public string BTCPayHostExecutable { get; set; } = conf["btcpayhostexecutable"] ?? "btcpay-host";
+ private readonly ILogger _logger = loggerFactory.CreateLogger("BTCPayServer.ProcessRunner");
+
+ #nullable enable
+ public async Task<HostCommandResult> RunHostCommand(string hostCommand, IReadOnlyList<string>? arguments, TimeSpan timeout)
+ {
+ using var timeoutCts = new CancellationTokenSource(timeout);
+ return await RunHostCommand(hostCommand, arguments, timeoutCts.Token);
+ }
+
+ public async Task<HostCommandResult> RunHostCommand(string hostCommand, IReadOnlyList<string>? arguments, CancellationToken cancellationToken)
+ {
+ var output = new OutputCapture();
+ var error = new OutputCapture();
+ var args = arguments?.ToList() ?? new();
+ args.Insert(0, hostCommand);
+ var exitCode = await RunAsync(new ProcessSpec
+ {
+ Executable = BTCPayHostExecutable,
+ Arguments = args,
+ OutputCapture = output,
+ ErrorCapture = error
+ }, cancellationToken);
+ return new HostCommandResult(exitCode, output.ToString(), error.ToString());
+ }
+#nullable restore
+ public async Task<int> RunAsync(ProcessSpec processSpec, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(processSpec);
+
+ int exitCode;
+ var stopwatch = new Stopwatch();
+
+ using (var process = CreateProcess(processSpec))
+ using (var processState = new ProcessState(process, _logger))
+ using (cancellationToken.Register(() => processState.TryKill()))
+ {
+ var readOutput = false;
+ var readError = false;
+ if (processSpec.IsErrorCaptured)
+ {
+ readError = true;
+ process.ErrorDataReceived += (_, a) =>
+ {
+ if (a.Data is not null)
+ processSpec.ErrorCapture.AddLine(a.Data);
+ };
+ }
+ if (processSpec.IsOutputCaptured)
+ {
+ readOutput = true;
+ process.OutputDataReceived += (_, a) =>
+ {
+ if (a.Data is not null)
+ processSpec.OutputCapture.AddLine(a.Data);
+ };
+ }
+
+ stopwatch.Start();
+ var arguments = process.StartInfo.Arguments != "" ? process.StartInfo.Arguments : string.Join(" ", process.StartInfo.ArgumentList);
+ _logger.LogInformation($"Running '{processSpec.Executable} {arguments}'");
+
+ process.Start();
+
+ if (readOutput)
+ process.BeginOutputReadLine();
+ if (readError)
+ process.BeginErrorReadLine();
+
+ if (processSpec.Stdin is not null)
+ {
+ foreach (var line in processSpec.Stdin)
+ await process.StandardInput.WriteLineAsync(line);
+ process.StandardInput.Close();
+ }
+ else if (processSpec.StdinContent is not null)
+ {
+ await process.StandardInput.WriteAsync(processSpec.StdinContent);
+ process.StandardInput.Close();
+ }
+
+ await processState.Task;
+
+ exitCode = process.ExitCode;
+ stopwatch.Stop();
+ _logger.LogInformation($"Process return {exitCode} and ran for {stopwatch.ElapsedMilliseconds}ms");
+ }
+
+ return exitCode;
+ }
+
+ private System.Diagnostics.Process CreateProcess(ProcessSpec processSpec)
+ {
+ var process = new System.Diagnostics.Process
+ {
+ EnableRaisingEvents = true,
+ StartInfo =
+ {
+ FileName = processSpec.Executable,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ WindowStyle = ProcessWindowStyle.Hidden,
+ WorkingDirectory = processSpec.WorkingDirectory,
+ RedirectStandardOutput = processSpec.IsOutputCaptured,
+ RedirectStandardError = processSpec.IsErrorCaptured,
+ RedirectStandardInput = processSpec.Stdin is not null || processSpec.StdinContent is not null
+ }
+ };
+
+ if (processSpec.EscapedArguments is not null)
+ {
+ process.StartInfo.Arguments = processSpec.EscapedArguments;
+ }
+ else if (processSpec.Arguments is not null)
+ {
+ for (var i = 0; i < processSpec.Arguments.Count; i++)
+ process.StartInfo.ArgumentList.Add(processSpec.Arguments[i]);
+ }
+
+ foreach (var env in processSpec.EnvironmentVariables)
+ process.StartInfo.Environment[env.Key] = env.Value;
+
+ SetEnvironmentVariable(process.StartInfo, "DOTNET_STARTUP_HOOKS", processSpec.EnvironmentVariables.DotNetStartupHooks, Path.PathSeparator);
+ SetEnvironmentVariable(process.StartInfo, "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", processSpec.EnvironmentVariables.AspNetCoreHostingStartupAssemblies, ';');
+
+ return process;
+ }
+
+ private static void SetEnvironmentVariable(ProcessStartInfo processStartInfo, string envVarName, List<string> envVarValues, char separator)
+ {
+ if (envVarValues is { Count: 0 })
+ return;
+
+ var existing = Environment.GetEnvironmentVariable(envVarName);
+ var result = !string.IsNullOrEmpty(existing) ? existing + separator + string.Join(separator, envVarValues) : string.Join(separator, envVarValues);
+ processStartInfo.EnvironmentVariables[envVarName] = result;
+ }
+
+ private class ProcessState : IDisposable
+ {
+ private readonly ILogger _logger;
+ private readonly System.Diagnostics.Process _process;
+ private readonly TaskCompletionSource<object> _tcs = new TaskCompletionSource<object>();
+ private volatile bool _disposed;
+
+ public ProcessState(System.Diagnostics.Process process, ILogger logger)
+ {
+ _logger = logger;
+ _process = process;
+ _process.Exited += OnExited;
+ Task = _tcs.Task.ContinueWith(_ =>
+ {
+ try
+ {
+ if (!_process.WaitForExit(int.MaxValue))
+ throw new TimeoutException();
+ _process.WaitForExit();
+ }
+ catch (InvalidOperationException) { }
+ });
+ }
+
+ public Task Task { get; }
+
+ public void TryKill()
+ {
+ if (_disposed)
+ return;
+
+ try
+ {
+ if (!_process.HasExited)
+ {
+ _logger.LogInformation($"Killing process {_process.Id}");
+ _process.Kill();
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogInformation($"Error while killing process '{_process.StartInfo.FileName} {_process.StartInfo.Arguments}': {ex.Message}");
+ }
+ }
+
+ private void OnExited(object sender, EventArgs args) => _tcs.TrySetResult(null);
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ TryKill();
+ _disposed = true;
+ _process.Exited -= OnExited;
+ _process.Dispose();
+ }
+ }
+}
+
+public class ProcessSpec
+{
+ public string Executable { get; set; }
+ public string WorkingDirectory { get; set; }
+ public ProcessSpecEnvironmentVariables EnvironmentVariables { get; } = new ProcessSpecEnvironmentVariables();
+ public IReadOnlyList<string> Arguments { get; set; }
+ public string EscapedArguments { get; set; }
+ public OutputCapture OutputCapture { get; set; }
+ public OutputCapture ErrorCapture { get; set; }
+ public bool IsOutputCaptured => OutputCapture != null;
+ public bool IsErrorCaptured => ErrorCapture != null;
+ public string[] Stdin { get; set; }
+ public string StdinContent { get; set; }
+
+ public string ShortDisplayName() => Path.GetFileNameWithoutExtension(Executable);
+
+ public sealed class ProcessSpecEnvironmentVariables : Dictionary<string, string>
+ {
+ public List<string> DotNetStartupHooks { get; } = new List<string>();
+ public List<string> AspNetCoreHostingStartupAssemblies { get; } = new List<string>();
+ }
+}
+
+public class OutputCapture
+{
+ private readonly List<string> _lines = new List<string>();
+ public IEnumerable<string> Lines => _lines;
+ public void AddLine(string line) => _lines.Add(line);
+ public override string ToString() => _lines.Count == 0 ? string.Empty : string.Join('\n', _lines) + '\n';
+}
### BTCPayServer/Views/UIServer/SSHService.cshtml
@@ -9,36 +9,7 @@
<vc:title-header />
</div>
<partial name="_StatusMessage" />
-<p text-translate="true">SSH services are used by the maintenance operations.</p>
-<div class="row">
- <div class="col-md-8">
- @if (!ViewContext.ModelState.IsValid)
- {
- <div asp-validation-summary="All"></div>
- }
-
- <div class="form-group">
- <div class="form-group">
- <label asp-for="CommandLine" class="form-label"></label>
- <input asp-for="CommandLine" class="form-control" readonly />
- </div>
- @if (!string.IsNullOrEmpty(Model.Password))
- {
- <div class="form-group">
- <label asp-for="Password" class="form-label"></label>
- <input asp-for="Password" class="form-control" readonly />
- </div>
- }
- @if (!string.IsNullOrEmpty(Model.KeyFilePassword))
- {
- <div class="form-group">
- <label asp-for="KeyFilePassword" class="form-label"></label>
- <input asp-for="KeyFilePassword" class="form-control" readonly />
- </div>
- }
- </div>
- </div>
-</div>
+<p text-translate="true">Maintenance operations use local BTCPay commands.</p>
@if (Model.SSHKeyFileContent != null)
{
@@ -56,25 +27,3 @@
</div>
</div>
}
-
-<h4 class="mt-5 mb-3" text-translate="true">Other actions</h4>
-<p text-translate="true">Increase the security of your instance by disabling the ability to change the SSH settings in this BTCPay Server instance's user interface.</p>
-<div class="row">
- <div class="col-md-8">
- <form method="post">
- <button name="command" id="disable" type="submit" class="btn btn-outline-danger mb-5" value="disable" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="DISABLE" text-translate="true">Disable</button>
- </form>
- </div>
-</div>
-
-<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Disable modification of SSH settings"], StringLocalizer["This action is permanent and will remove the ability to change the SSH settings via the BTCPay Server user interface."], StringLocalizer["Disable"]))"/>
-
-@section PageFootContent {
- <script>
- const disableButton = document.getElementById('disable')
- disableButton.dataset.action = window.location.href + '/disable'
- disableButton.addEventListener('click', event => {
- event.preventDefault()
- })
- </script>
-}
### BTCPayServer/Views/UIServer/ServerNavPages.cs
@@ -2,7 +2,7 @@ namespace BTCPayServer.Views.Server
{
public enum ServerNavPages
{
- Users, Emails, Policies, Branding, Services, Maintenance, Logs, Files,
+ Users, Emails, Policies, Branding, Services, Logs, Files,
Roles,
Stores,
Translations
### Docker/btcpay-host
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# In the BTCPay Server Docker image, btcpay-host forwards host command calls
+# over SSH to BTCPAY_SSHCONNECTION, using BTCPAY_SSHKEYFILE when configured.
+
+connection="${BTCPAY_SSHCONNECTION:-}"
+if [[ -z "$connection" ]]; then
+ echo "BTCPAY_SSHCONNECTION must be set to forward btcpay-host commands over SSH" >&2
+ exit 1
+fi
+
+host="$connection"
+ssh_args=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new)
+
+# BTCPAY_SSHCONNECTION can be user@host, user@host:port,
+# user@IPv6, or user@[IPv6]:port.
+# OpenSSH expects the port to be passed separately through -p.
+if [[ "$connection" =~ ^(.+@)?\[([^]]+)\](:([^:]+))?$ ]]; then
+ host="${BASH_REMATCH[1]:-}${BASH_REMATCH[2]}"
+ if [[ -n "${BASH_REMATCH[4]:-}" ]]; then
+ ssh_args+=(-p "${BASH_REMATCH[4]}")
+ fi
+elif [[ "${connection##*@}" == \[* ]]; then
+ echo "Invalid BTCPAY_SSHCONNECTION. Use user@[IPv6]:port for bracketed IPv6 SSH connections." >&2
+ exit 1
+elif [[ "${connection##*@}" == *:* && "${connection##*@}" != *:*:* ]]; then
+ host="${connection%:*}"
+ ssh_args+=(-p "${connection##*:}")
+fi
+
+if [[ -n "${BTCPAY_SSHKEYFILE:-}" ]]; then
+ ssh_args+=(-i "$BTCPAY_SSHKEYFILE")
+fi
+
+# Send argv as JSON on stdin.
+# The host SSH forced command should deserialize it and call its own btcpay-host.
+json="$(jq -cn --args '$ARGS.positional' "$@")"
+exec ssh "${ssh_args[@]}" "$host" btcpay-host <<< "$json"
### Docker/docker-entrypoint.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+echo "$(/sbin/ip route|awk '/default/ { print $3 }') host.docker.internal" >> /etc/hosts
+
+exec dotnet BTCPayServer.dll
### Dockerfile
@@ -23,7 +23,7 @@ RUN cd BTCPayServer && dotnet publish -p:GitCommit=${GIT_COMMIT} --output /app/
FROM mcr.microsoft.com/dotnet/aspnet:10.0.11-noble
-RUN apt-get update && apt-get install -y --no-install-recommends iproute2 openssh-client ca-certificates \
+RUN apt-get update && apt-get install -y --no-install-recommends iproute2 openssh-client ca-certificates jq \
&& rm -rf /var/lib/apt/lists/*
ENV LC_ALL en_US.UTF-8
@@ -36,5 +36,6 @@ ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
VOLUME /datadir
COPY --from=builder "/app" .
-COPY docker-entrypoint.sh docker-entrypoint.sh
+COPY --chmod=0755 Docker/btcpay-host /usr/local/bin/btcpay-host
+COPY --chmod=0755 Docker/docker-entrypoint.sh docker-entrypoint.sh
ENTRYPOINT ["/app/docker-entrypoint.sh"]
### btcpayserver.sln
@@ -1,4 +1,4 @@
-
+
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.1.32414.318
@@ -15,7 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{29290EC7-0
.github\workflows\ci.yml = .github\workflows\ci.yml
.github\workflows\release.yml = .github\workflows\release.yml
Build\Common.csproj = Build\Common.csproj
- docker-entrypoint.sh = docker-entrypoint.sh
+ Docker\docker-entrypoint.sh = Docker\docker-entrypoint.sh
+ Docker\btcpay-host = Docker\btcpay-host
Dockerfile = Dockerfile
Build\Version.csproj = Build\Version.csproj
EndProjectSection
### docker-entrypoint.sh
@@ -1,24 +0,0 @@
-#!/usr/bin/env bash
-
-echo "$(/sbin/ip route|awk '/default/ { print $3 }') host.docker.internal" >> /etc/hosts
-
-if [ -f "$BTCPAY_SSHAUTHORIZEDKEYS" ] && [[ "$BTCPAY_SSHKEYFILE" ]]; then
- if ! [ -f "$BTCPAY_SSHKEYFILE" ] || ! [ -f "$BTCPAY_SSHKEYFILE.pub" ]; then
- rm -f "$BTCPAY_SSHKEYFILE" "$BTCPAY_SSHKEYFILE.pub"
- echo "Creating BTCPay Server SSH key File..."
- ssh-keygen -t ed25519 -f "$BTCPAY_SSHKEYFILE" -q -P "" -m PEM -C btcpayserver > /dev/null
- # Let's make sure the SSHAUTHORIZEDKEYS doesn't have our key yet
- # Because the file is mounted, set -i does not work
- sed '/btcpayserver$/d' "$BTCPAY_SSHAUTHORIZEDKEYS" > "$BTCPAY_SSHAUTHORIZEDKEYS.new"
- cat "$BTCPAY_SSHAUTHORIZEDKEYS.new" > "$BTCPAY_SSHAUTHORIZEDKEYS"
- rm -rf "$BTCPAY_SSHAUTHORIZEDKEYS.new"
- fi
-
- if [ -f "$BTCPAY_SSHKEYFILE.pub" ] && \
- ! grep -q "btcpayserver$" "$BTCPAY_SSHAUTHORIZEDKEYS"; then
- echo "Adding BTCPay Server SSH key to authorized keys"
- cat "$BTCPAY_SSHKEYFILE.pub" >> "$BTCPAY_SSHAUTHORIZEDKEYS"
- fi
-fi
-
-exec dotnet BTCPayServer.dllWhy this scored 47/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.