Add Reverse Connection deployment mode
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:
parent
800a087cea
commit
26c43efdf0
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@ bin/
|
||||
obj/
|
||||
/packages/
|
||||
.idea
|
||||
.playwright-mcp
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.0.27</Version>
|
||||
<Version>0.0.28</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -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 =>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
111
BTCPayServerDockerConfigurator/Controllers/TunnelController.cs
Normal file
111
BTCPayServerDockerConfigurator/Controllers/TunnelController.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -4,5 +4,6 @@ public enum DeploymentType
|
||||
{
|
||||
ThisMachine,
|
||||
RemoteMachine,
|
||||
Manual
|
||||
Manual,
|
||||
ReverseConnection
|
||||
}
|
||||
|
||||
21
BTCPayServerDockerConfigurator/Models/IRemoteExecutor.cs
Normal file
21
BTCPayServerDockerConfigurator/Models/IRemoteExecutor.cs
Normal 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 "";
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
18
BTCPayServerDockerConfigurator/Models/SshRemoteExecutor.cs
Normal file
18
BTCPayServerDockerConfigurator/Models/SshRemoteExecutor.cs
Normal 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();
|
||||
}
|
||||
113
BTCPayServerDockerConfigurator/Models/TunnelSession.cs
Normal file
113
BTCPayServerDockerConfigurator/Models/TunnelSession.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
58
BTCPayServerDockerConfigurator/TunnelService.cs
Normal file
58
BTCPayServerDockerConfigurator/TunnelService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 — 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(){
|
||||
|
||||
@ -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 <(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>
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user