Public Access
Merge origin/main into claude/spelunk-tunnel
Port forwarding took host schema version 8 first, so the Spelunk route moves to 9 and is written after the forwards. A host may carry both.
This commit is contained in:
commit
689ba6c1bf
44 files changed
+3187
-75
No files matched your search
@@ -29,6 +29,14 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
||||
/// <summary>Requests this factory was asked for, in order.</summary>
|
||||
internal List<SshConnectionRequest> Requests { get; } = [];
|
||||
|
||||
/// <summary>Every forward started on any connection this factory opened, in order.</summary>
|
||||
internal List<FakeSshPortForward> Forwards { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a forward is refused, and with what sentence, when set. Null from it starts the forward.
|
||||
/// </summary>
|
||||
internal Func<SshPortForwardSpec, string?>? ForwardFailure { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Runs against each shell request before it succeeds or fails, when set.
|
||||
/// </summary>
|
||||
@@ -118,7 +126,7 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
||||
progress?.Report(SshConnectionPhase.CheckingHostKey);
|
||||
progress?.Report(SshConnectionPhase.Authenticating);
|
||||
|
||||
return new FakeSshConnection(request, Shells);
|
||||
return new FakeSshConnection(request, Shells, this);
|
||||
}
|
||||
|
||||
/// <summary>The file-transfer session this factory last handed out, or null when it never has.</summary>
|
||||
@@ -337,7 +345,10 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeSshConnection(SshConnectionRequest request, List<FakeSshShellSession> shells)
|
||||
internal sealed class FakeSshConnection(
|
||||
SshConnectionRequest request,
|
||||
List<FakeSshShellSession> shells,
|
||||
FakeSshConnectionFactory factory)
|
||||
: ISshConnection
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -361,6 +372,20 @@ internal sealed class FakeSshConnection(SshConnectionRequest request, List<FakeS
|
||||
return Task.FromResult<ISshShellSession>(shell);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshPortForward> StartForwardAsync(SshPortForwardSpec spec, CancellationToken cancellationToken)
|
||||
{
|
||||
if (factory.ForwardFailure?.Invoke(spec) is { } refusal)
|
||||
{
|
||||
return Task.FromException<ISshPortForward>(new SshPortForwardException(refusal));
|
||||
}
|
||||
|
||||
var forward = new FakeSshPortForward(spec);
|
||||
factory.Forwards.Add(forward);
|
||||
|
||||
return Task.FromResult<ISshPortForward>(forward);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -369,6 +394,43 @@ internal sealed class FakeSshConnection(SshConnectionRequest request, List<FakeS
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A forward that listens on nothing, and can be made to report a failed connection through it.</summary>
|
||||
internal sealed class FakeSshPortForward(SshPortForwardSpec spec) : ISshPortForward
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<SshPortForwardErrorEventArgs>? ErrorOccurred;
|
||||
|
||||
/// <inheritdoc />
|
||||
public SshPortForwardSpec Spec { get; } = spec;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int BoundPort => Spec.BindPort;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsListening { get; private set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? LastError { get; private set; }
|
||||
|
||||
/// <summary>Whether whoever started it has stopped it.</summary>
|
||||
internal bool IsDisposed { get; private set; }
|
||||
|
||||
/// <summary>Plays SSH.NET reporting a connection through the forward that failed.</summary>
|
||||
internal void Fail(string message)
|
||||
{
|
||||
LastError = message;
|
||||
ErrorOccurred?.Invoke(this, new SshPortForwardErrorEventArgs(message));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IsDisposed = true;
|
||||
IsListening = false;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A write stream that hands its contents over when it is closed.</summary>
|
||||
/// <remarks>
|
||||
/// A <see cref="MemoryStream"/> cannot be read after disposal, and disposal is exactly when a write is
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Port forwards, from the host editor that saves them to the tab they run on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The listeners here are fakes; <c>PortForwardTests</c> in the SSH suite carries real bytes through all
|
||||
/// three kinds against a real sshd. What this holds is everything around them: which forwards start, on
|
||||
/// which session, what a refusal looks like, and that none of it outlives the session.
|
||||
/// </remarks>
|
||||
public sealed partial class ShellFlowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AHostsForwards_StartWithItsTerminal_AndAreCountedInTheStatusBar()
|
||||
{
|
||||
var vault = await ConnectedHostWithForwardsAsync();
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
tab.IsLive.ShouldBeTrue();
|
||||
|
||||
await EventuallyAsync(() => tab.ListeningForwardCount == 2, "both forwards should be listening");
|
||||
|
||||
tab.Forwards.Select(row => row.Notation).ShouldBe(["-L 5432:db.internal:5432", "-D 1080"]);
|
||||
tab.ForwardsSummary.ShouldBe("⇄ 2 FORWARDS");
|
||||
|
||||
ssh.Forwards.Select(forward => forward.Spec).ShouldBe(
|
||||
[
|
||||
new SshPortForwardSpec(SshPortForwardKind.Local, 5432, "db.internal", 5432),
|
||||
new SshPortForwardSpec(SshPortForwardKind.Dynamic, 1080),
|
||||
]);
|
||||
|
||||
vault.SelectedHost.ShouldNotBeNull().HasForwards.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AForwardThatCannotStart_SaysWhy_AndStopsNothingElse()
|
||||
{
|
||||
ssh.ForwardFailure = spec => spec.BindPort == 5432 ? "Port 5432 on this machine is already in use." : null;
|
||||
|
||||
await ConnectedHostWithForwardsAsync();
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
|
||||
await EventuallyAsync(() => tab.Forwards.All(row => row.State is not PortForwardState.Starting), "both should settle");
|
||||
|
||||
var refused = tab.Forwards[0];
|
||||
refused.State.ShouldBe(PortForwardState.Failed);
|
||||
refused.StateText.ShouldBe("FAILED");
|
||||
refused.Detail.ShouldBe("Port 5432 on this machine is already in use.");
|
||||
|
||||
tab.Forwards[1].IsListening.ShouldBeTrue();
|
||||
tab.IsLive.ShouldBeTrue("a forward that cannot start is not a reason to lose the shell");
|
||||
tab.ForwardsSummary.ShouldBe("⇄ 1 FORWARD");
|
||||
|
||||
// The switch on a failed row is a retry, and the port has been freed since.
|
||||
ssh.ForwardFailure = null;
|
||||
await refused.ToggleCommand.ExecuteAsync(null);
|
||||
|
||||
refused.IsListening.ShouldBeTrue();
|
||||
refused.Detail.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AForwardCanBeSwitchedOffAndOnAgain_WithoutTouchingTheSession()
|
||||
{
|
||||
await ConnectedHostWithForwardsAsync();
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
await EventuallyAsync(() => tab.ListeningForwardCount == 2, "both forwards should be listening");
|
||||
|
||||
var row = tab.Forwards[0];
|
||||
var first = ssh.Forwards[0];
|
||||
|
||||
await row.ToggleCommand.ExecuteAsync(null);
|
||||
|
||||
row.State.ShouldBe(PortForwardState.Stopped);
|
||||
first.IsDisposed.ShouldBeTrue("switching off is what stops the listener");
|
||||
tab.ForwardsSummary.ShouldBe("⇄ 1 FORWARD");
|
||||
|
||||
await row.ToggleCommand.ExecuteAsync(null);
|
||||
|
||||
row.IsListening.ShouldBeTrue();
|
||||
ssh.Forwards.Count.ShouldBe(3, "a restart is a new listener on the same session");
|
||||
tab.IsLive.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AFailedConnectionThroughAForward_IsShownUnderIt_WhileItKeepsListening()
|
||||
{
|
||||
await ConnectedHostWithForwardsAsync();
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
await EventuallyAsync(() => tab.ListeningForwardCount == 2, "both forwards should be listening");
|
||||
|
||||
ssh.Forwards[0].Fail("Connection refused");
|
||||
|
||||
tab.Forwards[0].Detail.ShouldBe("Connection refused");
|
||||
tab.Forwards[0].IsListening.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WhenTheSessionEnds_ItsForwardsSayTheyWentWithIt()
|
||||
{
|
||||
await ConnectedHostWithForwardsAsync();
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
await EventuallyAsync(() => tab.ListeningForwardCount == 2, "both forwards should be listening");
|
||||
|
||||
ssh.Shells.ShouldHaveSingleItem().Drop("An established connection was aborted by the server.");
|
||||
|
||||
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||
|
||||
tab.Forwards.ShouldAllBe(row => row.State == PortForwardState.Stopped);
|
||||
tab.Forwards.ShouldAllBe(row => row.Detail == "Stopped with the session.");
|
||||
tab.Forwards.ShouldAllBe(row => !row.CanToggle);
|
||||
tab.ForwardsSummary.ShouldBeNull();
|
||||
ssh.Forwards.ShouldAllBe(forward => forward.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AReconnect_StartsTheForwardsAgainOnTheNewSession()
|
||||
{
|
||||
await ConnectedHostWithForwardsAsync();
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
await EventuallyAsync(() => tab.ListeningForwardCount == 2, "both forwards should be listening");
|
||||
|
||||
var before = tab.Forwards.ToList();
|
||||
|
||||
ssh.Shells[0].Drop("An established connection was aborted by the server.");
|
||||
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||
|
||||
await shell.ReconnectTabCommand.ExecuteAsync(tab);
|
||||
|
||||
await EventuallyAsync(() => tab.IsLive && tab.ListeningForwardCount == 2, "the new session's forwards should listen");
|
||||
|
||||
tab.Forwards.ShouldNotContain(before[0], "a reconnect is a new session, with new rows");
|
||||
ssh.Forwards.Count.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATypedInAddress_StartsNoForwards()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ManualTarget = "deploy@10.0.0.9";
|
||||
vault.ManualPassword = "hunter2";
|
||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Tabs.ShouldHaveSingleItem().Forwards.ShouldBeEmpty();
|
||||
ssh.Forwards.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- The phone's sheet ----
|
||||
|
||||
/// <remarks>
|
||||
/// The phone lists a tab's forwards in a sheet over the terminal, so it obeys the connect sheet's rules:
|
||||
/// it collapses the renderer while it is up, and leaving the terminal lowers it however it is left.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheForwardsSheet_HidesTheRenderer_AndLeavingTheTerminalLowersIt()
|
||||
{
|
||||
await ConnectedHostWithForwardsAsync();
|
||||
|
||||
shell.SelectedTab.ShouldNotBeNull().HasForwards.ShouldBeTrue();
|
||||
|
||||
shell.OpenForwardsSheetCommand.Execute(null);
|
||||
|
||||
shell.IsForwardsSheetOpen.ShouldBeTrue();
|
||||
shell.IsTerminalShowing.ShouldBeFalse("the sheet draws over the renderer's rectangle");
|
||||
shell.IsTerminalSurface.ShouldBeTrue("the bar the sheet was raised from is on that surface");
|
||||
|
||||
shell.CloseForwardsSheetCommand.Execute(null);
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
|
||||
shell.OpenForwardsSheetCommand.Execute(null);
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
||||
|
||||
shell.IsForwardsSheetOpen.ShouldBeFalse("a sheet left up over a page would hold the renderer blank");
|
||||
|
||||
shell.OpenForwardsSheetCommand.Execute(null);
|
||||
|
||||
shell.IsForwardsSheetOpen.ShouldBeFalse("it must not open over a page either");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// What the phone's bar button and its keep-alive notification read: whether a tab has forwards, whether
|
||||
/// one failed, and how many listen across every tab — with an event each time any of that moves.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheCountsAPhoneShows_FollowEveryForwardsState()
|
||||
{
|
||||
ssh.ForwardFailure = spec => spec.BindPort == 5432 ? "Port 5432 on this machine is already in use." : null;
|
||||
|
||||
var changes = 0;
|
||||
shell.ForwardsChanged += (_, _) => changes++;
|
||||
|
||||
await ConnectedHostWithForwardsAsync();
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
await EventuallyAsync(() => tab.Forwards.All(row => row.State is not PortForwardState.Starting), "both should settle");
|
||||
|
||||
tab.HasForwards.ShouldBeTrue();
|
||||
tab.HasFailedForward.ShouldBeTrue();
|
||||
tab.HasListeningForward.ShouldBeTrue();
|
||||
shell.ListeningForwardCount.ShouldBe(1);
|
||||
changes.ShouldBeGreaterThan(0);
|
||||
|
||||
var before = changes;
|
||||
ssh.ForwardFailure = null;
|
||||
await tab.Forwards[0].ToggleCommand.ExecuteAsync(null);
|
||||
|
||||
tab.HasFailedForward.ShouldBeFalse();
|
||||
shell.ListeningForwardCount.ShouldBe(2);
|
||||
changes.ShouldBeGreaterThan(before, "a retry that listens is a change the notification has to hear");
|
||||
|
||||
ssh.Shells.ShouldHaveSingleItem().Drop("An established connection was aborted by the server.");
|
||||
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||
|
||||
shell.ListeningForwardCount.ShouldBe(0);
|
||||
tab.HasListeningForward.ShouldBeFalse();
|
||||
tab.HasForwards.ShouldBeTrue("the rows stay, saying they went with the session");
|
||||
}
|
||||
|
||||
// ---- The editor ----
|
||||
|
||||
[Fact]
|
||||
public async Task TheEditor_RefusesASecondListenerOnAPortThisHostAlreadyListensOn()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
AddForward(vault, PortForwardKind.Local, "8080", "web.internal:80");
|
||||
AddForward(vault, PortForwardKind.Dynamic, "8080", string.Empty);
|
||||
|
||||
vault.Status.ShouldBe("This host already has a forward listening on port 8080 here.");
|
||||
vault.EditorPortForwards.Count.ShouldBe(1);
|
||||
|
||||
// A remote forward listens on the remote, whose ports are a different set.
|
||||
AddForward(vault, PortForwardKind.Remote, "8080", "localhost:3000");
|
||||
|
||||
vault.EditorPortForwards.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "db:5432", "A forward needs a listening port, as a number.")]
|
||||
[InlineData("-1", "db:5432", "A forward needs a listening port, as a number.")]
|
||||
[InlineData("70000", "db:5432", "A forward's listening port must be between 1 and 65535, not 70000.")]
|
||||
[InlineData("5432", "db", "A forward's target is host:port, for example db.internal:5432.")]
|
||||
[InlineData("5432", "::1:5432", "A forward's target is host:port, for example db.internal:5432.")]
|
||||
[InlineData("5432", "db:0", "A forward's target port must be between 1 and 65535, not 0.")]
|
||||
public async Task TheEditor_RefusesAnUnusableForwardAtTheBox(string bindPort, string target, string expected)
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
AddForward(vault, PortForwardKind.Local, bindPort, target);
|
||||
|
||||
vault.Status.ShouldBe(expected);
|
||||
vault.EditorPortForwards.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("db.internal:5432", "db.internal", 5432)]
|
||||
[InlineData(" localhost:3000 ", "localhost", 3000)]
|
||||
[InlineData("[::1]:5432", "::1", 5432)]
|
||||
[InlineData("10.0.0.9:22", "10.0.0.9", 22)]
|
||||
public void AForwardTarget_IsReadAsHostColonPort(string text, string host, int port)
|
||||
{
|
||||
VaultViewModel.TryParseForwardTarget(text, out var parsedHost, out var parsedPort).ShouldBeTrue();
|
||||
|
||||
parsedHost.ShouldBe(host);
|
||||
parsedPort.ShouldBe(port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditingAHost_KeepsItsForwardsIds_SoTheMergeSeesAnEditNotANewForward()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
AddForward(vault, PortForwardKind.Local, "5432", "db.internal:5432");
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var saved = vault.Hosts[0].Host.PortForwards.ShouldHaveSingleItem();
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNotes = "primary";
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Hosts[0].Host.PortForwards.ShouldHaveSingleItem().ShouldBe(saved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A host with a bound key and two forwards — one local, one SOCKS — connected, with its tab selected.
|
||||
/// </summary>
|
||||
private async Task<VaultViewModel> ConnectedHostWithForwardsAsync()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
AddForward(vault, PortForwardKind.Local, "5432", "db.internal:5432");
|
||||
AddForward(vault, PortForwardKind.Dynamic, "1080", string.Empty);
|
||||
vault.EditorPortForwards.Count.ShouldBe(2, vault.Status);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
|
||||
|
||||
return vault;
|
||||
}
|
||||
|
||||
private static void AddForward(VaultViewModel vault, PortForwardKind kind, string bindPort, string target)
|
||||
{
|
||||
vault.EditorNewForwardKind = (int)kind;
|
||||
vault.EditorNewForwardBindPort = bindPort;
|
||||
vault.EditorNewForwardTarget = target;
|
||||
vault.AddEditorForwardCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user