Add Reverse Connection deployment mode
Some checks failed
Build and publish Docker images / build (push) Has been cancelled
Build and publish Docker images / publish (push) Has been cancelled

New deployment option where the user runs a bash one-liner on their
VPS that connects back to the configurator over HTTP. The configurator
sends commands through the tunnel to read server configuration,
avoiding outbound SSH entirely. This prevents VPS abuse detection
flags from rapid SSH connect/disconnect patterns.

Architecture:
- IRemoteExecutor interface abstracts command execution over SSH
  or HTTP tunnel, letting LoadSettings work with either transport
- TunnelSession uses System.Threading.Channels for synchronized
  command/result handoff between configurator and polling agent
- TunnelService manages session lifecycle with auto-cleanup
- TunnelController serves the agent script and handles poll/result
- One-time secret per session prevents unauthorized access
- At deploy time, generates a bash script (same as Manual mode)

Bump version to 0.0.28.
This commit is contained in:
Andrew Camilleri 2026-05-04 15:12:47 +02:00
parent 800a087cea
commit 26c43efdf0
No known key found for this signature in database
GPG Key ID: 8E5530D9D1C93097
15 changed files with 558 additions and 17 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ bin/
obj/
/packages/
.idea
.playwright-mcp

View File

@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.0.27</Version>
<Version>0.0.28</Version>
</PropertyGroup>
<ItemGroup>

View File

@ -8,6 +8,8 @@ public static class ConfiguratorExtensions
{
services.AddOptions();
services.AddSingleton<DeploymentService>();
services.AddSingleton<TunnelService>();
services.AddHostedService(sp => sp.GetRequiredService<TunnelService>());
services.AddOptions<ConfiguratorOptions>();
services.AddHttpClient();
services.PostConfigure<ConfiguratorOptions>(options =>

View File

@ -22,7 +22,8 @@ public partial class ConfiguratorController
{
var model = GetConfiguratorSettings();
if (model.DeploymentSettings.DeploymentType == DeploymentType.Manual)
if (model.DeploymentSettings.DeploymentType is DeploymentType.Manual
or DeploymentType.ReverseConnection)
{
var id = Guid.NewGuid().ToString();
var result = new UpdateSettings<ConfiguratorSettings, DeployAdditionalData>

View File

@ -102,6 +102,44 @@ public partial class ConfiguratorController
break;
}
case DeploymentType.ReverseConnection when ModelState.IsValid:
{
var session = _tunnelService.CreateSession();
var rootPassword = updateSettings.Settings.RootPassword;
var deploymentSettings = updateSettings.Settings;
_ = Task.Run(async () =>
{
try
{
await session.WaitForAgent(TimeSpan.FromMinutes(5));
if (session.State == TunnelState.Error) return;
session.State = TunnelState.Loading;
if (!await TestRemoteRoot(session, rootPassword))
{
session.State = TunnelState.Error;
session.ErrorMessage = "Could not verify root access";
return;
}
session.LoadedSettings =
await LoadSettingsThroughSSH(deploymentSettings, session);
session.State = TunnelState.Done;
}
catch (TaskCanceledException)
{
session.State = TunnelState.Expired;
session.ErrorMessage = "Timed out waiting for agent";
}
catch (Exception ex)
{
session.State = TunnelState.Error;
session.ErrorMessage = ex.Message;
}
});
return RedirectToAction("TunnelSetup",
new { secret = session.Secret });
}
}
if (!ModelState.IsValid)
@ -114,9 +152,10 @@ public partial class ConfiguratorController
(updateSettings.Settings.DeploymentType == DeploymentType.RemoteMachine &&
updateSettings.Additional.LoadFromServer))
{
configuratorSettings =
await LoadSettingsThroughSSH(updateSettings.Settings, sshClient);
using var executor = new SshRemoteExecutor(sshClient);
sshClient = null;
configuratorSettings =
await LoadSettingsThroughSSH(updateSettings.Settings, executor);
}
else
{
@ -181,21 +220,22 @@ public partial class ConfiguratorController
}
additionalData.AvailableDeploymentTypes.Add(DeploymentType.RemoteMachine);
additionalData.AvailableDeploymentTypes.Add(DeploymentType.ReverseConnection);
additionalData.AvailableDeploymentTypes.Add(DeploymentType.Manual);
return additionalData;
}
private async Task<string> GetVar(Dictionary<string, string> dictionary, SshClient client,
string name)
private async Task<string> GetVar(Dictionary<string, string> dictionary,
IRemoteExecutor executor, string name)
{
if (dictionary.TryGetValue(name, out var value))
return value;
return await client.GetEnvVar(name);
return await executor.GetEnvVar(name);
}
public async Task<ConfiguratorSettings> LoadSettingsThroughSSH(
DeploymentSettings settings, SshClient ssh)
DeploymentSettings settings, IRemoteExecutor ssh)
{
var result = new ConfiguratorSettings
{
@ -347,4 +387,59 @@ public partial class ConfiguratorController
return result;
}
private async Task<bool> TestRemoteRoot(IRemoteExecutor executor, string rootPassword)
{
var whoami = await executor.RunBash("whoami");
if (whoami.Output.Contains("root", StringComparison.InvariantCultureIgnoreCase))
return true;
var sudoWhoami = string.IsNullOrEmpty(rootPassword)
? await executor.RunBash("sudo whoami")
: await executor.RunBash(
$"echo \"{rootPassword}\" | sudo -S whoami");
return sudoWhoami.Output.Contains("root",
StringComparison.InvariantCultureIgnoreCase);
}
[HttpGet("tunnel-setup/{secret}")]
public IActionResult TunnelSetup(string secret)
{
var session = _tunnelService.GetSession(secret);
if (session == null)
return RedirectToAction("DeploymentDestination");
var baseUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
ViewBag.Secret = secret;
ViewBag.Command =
$"bash <(curl -sSf {baseUrl}/api/tunnel/{secret}/agent)";
return View();
}
[HttpGet("tunnel-status/{secret}")]
public IActionResult TunnelStatus(string secret)
{
var session = _tunnelService.GetSession(secret);
if (session == null)
return Json(new { state = "expired" });
return Json(new
{
state = session.State.ToString().ToLowerInvariant(),
error = session.ErrorMessage
});
}
[HttpGet("tunnel-complete/{secret}")]
public IActionResult TunnelComplete(string secret)
{
var session = _tunnelService.GetSession(secret);
if (session?.State != TunnelState.Done || session.LoadedSettings == null)
return RedirectToAction("DeploymentDestination");
SetConfiguratorSettings(session.LoadedSettings);
_tunnelService.RemoveSession(secret);
return RedirectToAction(nameof(DomainSettings));
}
}

View File

@ -13,6 +13,7 @@ public partial class ConfiguratorController : Controller
private readonly ILogger<ConfiguratorController> _logger;
private readonly DeploymentService _deploymentService;
private readonly IHttpClientFactory _httpClientFactory;
private readonly TunnelService _tunnelService;
public bool IsVerified
{
@ -35,12 +36,14 @@ public partial class ConfiguratorController : Controller
public ConfiguratorController(IOptions<ConfiguratorOptions> options,
ILogger<ConfiguratorController> logger,
DeploymentService deploymentService,
IHttpClientFactory httpClientFactory)
IHttpClientFactory httpClientFactory,
TunnelService tunnelService)
{
_options = options;
_logger = logger;
_deploymentService = deploymentService;
_httpClientFactory = httpClientFactory;
_tunnelService = tunnelService;
}
private ConfiguratorSettings GetConfiguratorSettings()
@ -83,6 +86,7 @@ public partial class ConfiguratorController : Controller
switch (configuratorSettings.DeploymentSettings.DeploymentType)
{
case DeploymentType.Manual:
case DeploymentType.ReverseConnection:
break;
case DeploymentType.ThisMachine:
try

View File

@ -0,0 +1,111 @@
using BTCPayServerDockerConfigurator.Models;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServerDockerConfigurator.Controllers;
[Route("api/tunnel/{secret}")]
[ApiController]
public class TunnelController : ControllerBase
{
private readonly TunnelService _tunnelService;
public TunnelController(TunnelService tunnelService)
{
_tunnelService = tunnelService;
}
[HttpGet("agent")]
public IActionResult GetAgentScript(string secret)
{
var session = _tunnelService.GetSession(secret);
if (session == null)
return NotFound("Invalid or expired session");
var baseUrl =
$"{Request.Scheme}://{Request.Host}{Request.PathBase}/api/tunnel/{secret}";
var script = $$"""
#!/bin/bash
set -e
BASE_URL="{{baseUrl}}"
cleanup() { curl -sS -X POST "$BASE_URL/disconnect" 2>/dev/null || true; }
trap cleanup EXIT INT TERM
echo "Connected to BTCPay Server Configurator."
echo "Waiting for commands... (press Ctrl+C to disconnect)"
echo ""
while true; do
resp=$(curl -sS --max-time 30 "$BASE_URL/poll" 2>/dev/null) || { sleep 2; continue; }
if [ "$resp" = "##WAIT##" ]; then
continue
fi
if [ "$resp" = "##END##" ]; then
echo "Session complete."
break
fi
output=$(bash -c "$resp" 2>&1) || true
exitcode=$?
curl -sS -X POST "$BASE_URL/result" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "output=$output" \
-d "exit=$exitcode" 2>/dev/null
done
""";
return Content(script, "text/plain");
}
[HttpGet("poll")]
public async Task<IActionResult> Poll(string secret)
{
var session = _tunnelService.GetSession(secret);
if (session == null)
return Ok("##END##");
if (session.State is TunnelState.Done or TunnelState.Expired)
return Ok("##END##");
var command = await session.GetNextCommand(HttpContext.RequestAborted);
return Ok(command ?? "##WAIT##");
}
[HttpPost("result")]
public async Task<IActionResult> Result(string secret, [FromForm] string output,
[FromForm] int exit)
{
var session = _tunnelService.GetSession(secret);
if (session == null)
return NotFound();
await session.SetResult(output ?? "", exit);
return Ok();
}
[HttpPost("disconnect")]
public IActionResult Disconnect(string secret)
{
var session = _tunnelService.GetSession(secret);
if (session == null)
return NotFound();
session.OnDisconnect();
return Ok();
}
[HttpGet("status")]
public IActionResult Status(string secret)
{
var session = _tunnelService.GetSession(secret);
if (session == null)
return Ok(new { state = "expired" });
return Ok(new
{
state = session.State.ToString().ToLowerInvariant(),
error = session.ErrorMessage
});
}
}

View File

@ -4,5 +4,6 @@ public enum DeploymentType
{
ThisMachine,
RemoteMachine,
Manual
Manual,
ReverseConnection
}

View File

@ -0,0 +1,21 @@
namespace BTCPayServerDockerConfigurator.Models;
public interface IRemoteExecutor : IDisposable
{
Task<SSHClientExtensions.SSHCommandResult> RunBash(string command, TimeSpan? timeout = null);
}
public static class RemoteExecutorExtensions
{
public static async Task<string> GetEnvVar(this IRemoteExecutor executor, string name,
TimeSpan? timeout = null)
{
var result = await executor.RunBash($"echo \"${name}\"", timeout);
if (string.IsNullOrEmpty(result.Error) && result.ExitStatus == 0)
{
return result.Output.Replace("\n", "").Replace(Environment.NewLine, "").Trim();
}
return "";
}
}

View File

@ -1,6 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Renci.SshNet;
namespace BTCPayServerDockerConfigurator.Models;
@ -23,7 +22,7 @@ public class ServerData
public BitcoinNodeInfo BitcoinNode { get; set; }
public bool Loaded { get; set; }
public static async Task<ServerData> Load(SshClient ssh)
public static async Task<ServerData> Load(IRemoteExecutor ssh)
{
var result = new ServerData();
var cmd = await ssh.RunBash(FetchMemoryCommand);

View File

@ -0,0 +1,18 @@
using Renci.SshNet;
namespace BTCPayServerDockerConfigurator.Models;
public class SshRemoteExecutor : IRemoteExecutor
{
private readonly SshClient _client;
public SshRemoteExecutor(SshClient client) => _client = client;
public SshClient Client => _client;
public Task<SSHClientExtensions.SSHCommandResult> RunBash(string command,
TimeSpan? timeout = null)
=> _client.RunBash(command, timeout);
public void Dispose() => _client.Dispose();
}

View File

@ -0,0 +1,113 @@
using System.Threading.Channels;
namespace BTCPayServerDockerConfigurator.Models;
public enum TunnelState
{
WaitingForAgent,
Connected,
Loading,
Done,
Error,
Expired
}
public class TunnelSession : IRemoteExecutor
{
public string Secret { get; }
public TunnelState State { get; set; } = TunnelState.WaitingForAgent;
public ConfiguratorSettings LoadedSettings { get; set; }
public string ErrorMessage { get; set; }
public DateTime CreatedAt { get; } = DateTime.UtcNow;
public DateTime LastActivity { get; set; } = DateTime.UtcNow;
private readonly Channel<string> _commandChannel = Channel.CreateBounded<string>(1);
private readonly Channel<SSHClientExtensions.SSHCommandResult> _resultChannel =
Channel.CreateBounded<SSHClientExtensions.SSHCommandResult>(1);
private readonly TaskCompletionSource _agentConnected = new();
public TunnelSession(string secret) => Secret = secret;
public async Task<SSHClientExtensions.SSHCommandResult> RunBash(string command,
TimeSpan? timeout = null)
{
timeout ??= TimeSpan.FromSeconds(60);
using var cts = new CancellationTokenSource(timeout.Value);
try
{
await _commandChannel.Writer.WriteAsync(command, cts.Token);
return await _resultChannel.Reader.ReadAsync(cts.Token);
}
catch (ChannelClosedException)
{
return new SSHClientExtensions.SSHCommandResult
{
Output = "",
Error = ErrorMessage ?? "Agent disconnected",
ExitStatus = -1
};
}
}
public async Task<string> GetNextCommand(CancellationToken ct)
{
LastActivity = DateTime.UtcNow;
if (State == TunnelState.WaitingForAgent)
{
State = TunnelState.Connected;
_agentConnected.TrySetResult();
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(25));
try
{
return await _commandChannel.Reader.ReadAsync(cts.Token);
}
catch (OperationCanceledException)
{
return null;
}
}
public async Task SetResult(string output, int exitCode, CancellationToken ct = default)
{
LastActivity = DateTime.UtcNow;
await _resultChannel.Writer.WriteAsync(new SSHClientExtensions.SSHCommandResult
{
Output = output?.TrimEnd('\n') ?? "",
Error = "",
ExitStatus = exitCode
}, ct);
}
public void OnDisconnect()
{
State = TunnelState.Error;
ErrorMessage = "Agent disconnected";
_agentConnected.TrySetResult();
_commandChannel.Writer.TryComplete(new OperationCanceledException("Agent disconnected"));
_resultChannel.Writer.TryComplete(new OperationCanceledException("Agent disconnected"));
}
public Task WaitForAgent(TimeSpan timeout)
{
var cts = new CancellationTokenSource(timeout);
cts.Token.Register(() => _agentConnected.TrySetCanceled());
return _agentConnected.Task;
}
public void Complete()
{
_commandChannel.Writer.TryComplete();
_resultChannel.Writer.TryComplete();
}
public void Dispose()
{
Complete();
}
}

View File

@ -0,0 +1,58 @@
using System.Collections.Concurrent;
using BTCPayServerDockerConfigurator.Models;
namespace BTCPayServerDockerConfigurator;
public class TunnelService : IHostedService
{
private readonly ConcurrentDictionary<string, TunnelSession> _sessions = new();
private Timer _cleanupTimer;
public TunnelSession CreateSession()
{
var secret = Guid.NewGuid().ToString("N");
var session = new TunnelSession(secret);
_sessions.TryAdd(secret, session);
return session;
}
public TunnelSession GetSession(string secret)
{
return _sessions.TryGetValue(secret, out var session) ? session : null;
}
public void RemoveSession(string secret)
{
if (_sessions.TryRemove(secret, out var session))
{
session.Dispose();
}
}
public Task StartAsync(CancellationToken ct)
{
_cleanupTimer = new Timer(_ =>
{
var cutoff = DateTime.UtcNow.AddMinutes(-10);
foreach (var kvp in _sessions)
{
if (kvp.Value.LastActivity < cutoff)
{
kvp.Value.State = TunnelState.Expired;
RemoveSession(kvp.Key);
}
}
}, null, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct)
{
_cleanupTimer?.Dispose();
foreach (var kvp in _sessions)
kvp.Value.Dispose();
_sessions.Clear();
return Task.CompletedTask;
}
}

View File

@ -41,6 +41,21 @@
</label>
</div>
}
@if (Model.Additional.AvailableDeploymentTypes.Contains(DeploymentType.ReverseConnection))
{
<div class="col-md-@(12 / Model.Additional.AvailableDeploymentTypes.Count)">
<label class="card-input-element-label">
<input type="radio" asp-for="Settings.DeploymentType" value="@DeploymentType.ReverseConnection" class="card-input-element d-none"/>
<div class="card shadow-sm w-100 h-100 mt-4 mt-md-0">
<div class="card-img-top" style="background-image: url(assets/shell.png);"></div>
<div class="card-body">
<h3 class="card-title text-center">Reverse Connection</h3>
<p class="card-text">Run a command on your server that connects back to us. No SSH credentials needed &mdash; your server reaches out, not the other way around.</p>
</div>
</div>
</label>
</div>
}
@if (Model.Additional.AvailableDeploymentTypes.Contains(DeploymentType.Manual))
{
<div class="col-md-@(12 / Model.Additional.AvailableDeploymentTypes.Count)">
@ -113,6 +128,21 @@
</div>
</div>
<div class="row mb-5" style="display: none" id="reverse-connection-form">
<div class="col-md-6 col-sm-12 offset-md-3">
<div class="card shadow-sm">
<div class="card-body">
<h3 class="card-title text-center pt-2">Reverse Connection</h3>
<p class="text-muted">We'll give you a command to run on your server. It connects back here so we can read your current configuration. No SSH credentials leave your machine.</p>
<div class="form-group">
<label asp-for="Settings.RootPassword">Root/sudo password (if your SSH user is not root)</label>
<input type="password" class="form-control" asp-for="Settings.RootPassword" placeholder="Leave blank if running as root" value="@Model.Settings.RootPassword">
</div>
</div>
</div>
</div>
</div>
<div class="sticky-cta d-flex justify-content-center">
<button type="submit" class="btn btn-primary btn-lg btn-continue">Continue</button>
</div>
@ -132,11 +162,8 @@
function toggleForm(){
var selectedDeploymentType = $('input[name=Settings\\.DeploymentType]:checked').val();
if(selectedDeploymentType === "@nameof(DeploymentType.RemoteMachine)"){
$("#remote-machine-form").show();
}else{
$("#remote-machine-form").hide();
}
$("#remote-machine-form").toggle(selectedDeploymentType === "@nameof(DeploymentType.RemoteMachine)");
$("#reverse-connection-form").toggle(selectedDeploymentType === "@nameof(DeploymentType.ReverseConnection)");
}
function toggleAuthMethod(){

View File

@ -0,0 +1,90 @@
@{
ViewData["Title"] = "Connect your server";
ViewData["CurrentStep"] = 1;
var secret = (string)ViewBag.Secret;
var command = (string)ViewBag.Command;
}
<div class="container">
<div class="row mb-4">
<h2 class="text-center w-100">@ViewData["Title"]</h2>
</div>
<div class="row mb-4">
<div class="col-md-8 col-sm-12 offset-md-2">
<div class="card shadow-sm">
<div class="card-body">
<h3 class="card-title text-center mb-3">Run this on your server</h3>
<p class="text-muted text-center">Open a terminal on your VPS and paste this command. The configurator will read your server's current setup.</p>
<div class="input-group mb-3">
<input type="text" class="form-control font-monospace" value="@command" id="tunnel-command" readonly onclick="this.select();">
<button class="btn btn-outline-primary" type="button" id="copy-btn" onclick="copyCommand()">Copy</button>
</div>
<div class="alert alert-info small mb-0">
<strong>Tip:</strong> If your SSH user is not root, either run with <code>sudo bash &lt;(curl ...)</code> or provide the root password below.
</div>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6 col-sm-12 offset-md-3 text-center">
<div id="status-waiting">
<div class="spinner-border text-primary mb-2" role="status"></div>
<p class="text-muted" id="status-text">Waiting for your server to connect...</p>
</div>
<div id="status-error" style="display:none">
<div class="alert alert-danger" id="error-message"></div>
<a href="@Url.Action("DeploymentDestination")" class="btn btn-secondary">Back</a>
</div>
</div>
</div>
</div>
@section Scripts
{
<script>
function copyCommand() {
var input = document.getElementById('tunnel-command');
input.select();
navigator.clipboard.writeText(input.value);
var btn = document.getElementById('copy-btn');
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 2000);
}
function pollStatus() {
$.getJSON('@Url.Action("TunnelStatus", new { secret })', function(data) {
switch (data.state) {
case 'waitingforagent':
$('#status-text').text('Waiting for your server to connect...');
setTimeout(pollStatus, 2000);
break;
case 'connected':
$('#status-text').text('Connected! Testing access...');
setTimeout(pollStatus, 2000);
break;
case 'loading':
$('#status-text').text('Reading server configuration...');
setTimeout(pollStatus, 2000);
break;
case 'done':
window.location.href = '@Url.Action("TunnelComplete", new { secret })';
break;
case 'error':
$('#status-waiting').hide();
$('#status-error').show();
$('#error-message').text(data.error || 'An error occurred');
break;
case 'expired':
$('#status-waiting').hide();
$('#status-error').show();
$('#error-message').text('Session expired. Please try again.');
break;
}
}).fail(function() {
setTimeout(pollStatus, 3000);
});
}
$(document).ready(pollStatus);
</script>
}