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
@@ -187,6 +187,13 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
vault.EditorNewPin = "/var/www/a-fairly-long-application-directory-name";
|
||||
vault.AddEditorPinCommand.Execute(null);
|
||||
|
||||
// And a forward, for the same reason: PORT FORWARDS draws its rows in the same mono face, and its
|
||||
// add row is three controls abreast in a column 304 pixels wide.
|
||||
vault.EditorNewForwardBindPort = "15432";
|
||||
vault.EditorNewForwardTarget = "customer-a-production-database.internal:5432";
|
||||
vault.AddEditorForwardCommand.Execute(null);
|
||||
vault.EditorPortForwards.ShouldHaveSingleItem(vault.Status);
|
||||
|
||||
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
@@ -1013,9 +1020,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The FIELDS column is the one that grows: it is a list of names, and a host has thirteen of them —
|
||||
/// eleven until inheritance added "Password prompt" and tags added "Tags". Measured with an edit that
|
||||
/// touched several, because one field name fits anywhere. The count is stated rather than derived, so
|
||||
/// The FIELDS column is the one that grows: it is a list of names, and a host has fifteen of them —
|
||||
/// eleven until inheritance added "Password prompt", tags added "Tags", forwards added "Port forwards"
|
||||
/// and pinning added "Pinned paths". Measured with an edit that touched several, because one field
|
||||
/// name fits anywhere. The count is stated rather than derived, so
|
||||
/// it has to be recounted against <c>HostKind.Changes</c> whenever a field is added; the literal in
|
||||
/// <c>SeedLogsAsync</c> is the thing that actually keeps the column measured at its worst.
|
||||
/// </remarks>
|
||||
@@ -2244,7 +2252,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
ItemId = Guid.CreateVersion7(),
|
||||
ItemLabel = "customer-a-production-database",
|
||||
Operation = ActivityOperation.Updated,
|
||||
ChangedFields = "Hostname, Port, Username, Options, Password prompt, Group, Tags",
|
||||
ChangedFields = "Hostname, Port, Username, Options, Password prompt, Group, Tags, Port forwards, Pinned paths",
|
||||
At = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
|
||||
DeviceName = "jaap-jan-workstation",
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
@@ -185,6 +186,73 @@ public sealed class SessionSidebarTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// FORWARDS at its widest: a local forward to a long name, listening, and one that could not start with
|
||||
/// its reason wrapped under it. The state word sits at the end of the row beside a trimming notation,
|
||||
/// which is the same shape as the address and its close button above — the word must not be pushed out.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheForwardsList_FitsWithALongTargetAndAFailureReason()
|
||||
{
|
||||
await OnTheSidebarAsync(
|
||||
(_, window) => LayoutHarness.Unreachable(window).ShouldBeEmpty(),
|
||||
tab =>
|
||||
{
|
||||
var listening = Forward(15432, "customer-a-production-database.eu-west-1.internal", refusal: null);
|
||||
var refused = Forward(
|
||||
8080, "web.internal", "The remote would not listen on port 8080. It may be in use there, below 1024, or forbidden by the server's AllowTcpForwarding setting.");
|
||||
|
||||
tab.ReplaceForwards([listening, refused]);
|
||||
|
||||
// Both starts complete synchronously against these stand-ins, so neither needs awaiting on a
|
||||
// harness whose body is synchronous.
|
||||
_ = listening.StartAsync();
|
||||
_ = refused.StartAsync();
|
||||
|
||||
listening.IsListening.ShouldBeTrue();
|
||||
refused.IsFailed.ShouldBeTrue();
|
||||
});
|
||||
}
|
||||
|
||||
private static PortForwardViewModel Forward(int port, string target, string? refusal) =>
|
||||
new(
|
||||
new PortForwardRule
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Kind = PortForwardKind.Local,
|
||||
BindPort = port,
|
||||
TargetHost = target,
|
||||
TargetPort = port,
|
||||
},
|
||||
(spec, _) => refusal is null
|
||||
? Task.FromResult(Substitute.For<ISshPortForward>())
|
||||
: Task.FromException<ISshPortForward>(new SshPortForwardException(refusal)),
|
||||
action => action());
|
||||
|
||||
private Task OnTheSidebarAsync(Action<SessionSidebar, Window> body, Action<TerminalTabViewModel> arrange) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
shell.Tabs.Add(new TerminalTabViewModel(1, "db-primary", LongAddress));
|
||||
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
||||
|
||||
arrange(shell.Tabs[0]);
|
||||
|
||||
var sidebar = new SessionSidebar { DataContext = shell, ShowsSnips = true, ShowsForwards = true };
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
sidebar, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||
|
||||
try
|
||||
{
|
||||
body(sidebar, window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
private Task OnTheSidebarAsync(Action<SessionSidebar, Window> body) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,29 @@ internal static class HostFactory
|
||||
/// <summary>A second tag id, for the tests about two people tagging one host.</summary>
|
||||
internal static Guid EuWest { get; } = Guid.Parse("0192f0c8-9999-7c3d-8e4f-5a6b7c8d9e09");
|
||||
|
||||
/// <summary>A forward to the database, as the host would carry it.</summary>
|
||||
internal static PortForwardRule Postgres { get; } = new()
|
||||
{
|
||||
Id = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a"),
|
||||
Kind = PortForwardKind.Local,
|
||||
BindPort = 5432,
|
||||
TargetHost = "db.internal",
|
||||
TargetPort = 5432,
|
||||
};
|
||||
|
||||
/// <summary>A SOCKS forward, for the tests about two forwards on one host.</summary>
|
||||
internal static PortForwardRule Socks { get; } = new()
|
||||
{
|
||||
Id = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
|
||||
Kind = PortForwardKind.Dynamic,
|
||||
BindPort = 1080,
|
||||
};
|
||||
|
||||
/// <summary>A credential holding a Spelunk API key, for the hosts reached through Spelunk.</summary>
|
||||
internal static Guid SpelunkKey { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a");
|
||||
internal static Guid SpelunkKey { get; } = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
|
||||
|
||||
/// <summary>A server's id in Spelunk.</summary>
|
||||
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
|
||||
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d");
|
||||
|
||||
/// <summary>A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/>.</summary>
|
||||
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com") =>
|
||||
@@ -51,6 +69,7 @@ internal static class HostFactory
|
||||
Guid? groupId = null,
|
||||
Guid[]? tags = null,
|
||||
string[]? pinnedPaths = null,
|
||||
PortForwardRule[]? portForwards = null,
|
||||
SpelunkRoute? spelunk = null) =>
|
||||
new()
|
||||
{
|
||||
@@ -70,6 +89,7 @@ internal static class HostFactory
|
||||
GroupId = groupId,
|
||||
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
|
||||
PinnedPaths = pinnedPaths is null ? PinnedPathList.Empty : PinnedPathList.Create(pinnedPaths),
|
||||
PortForwards = portForwards is null ? PortForwardList.Empty : PortForwardList.Create(portForwards),
|
||||
Spelunk = spelunk,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ public sealed class HostSecretCodecTests
|
||||
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
|
||||
/// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
|
||||
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags, a
|
||||
/// pair of pinned paths and a Spelunk route — which are orthogonal to the binding and are what make this
|
||||
/// host reach the highest schema version a valid host can. The route and the relay are exclusive in the
|
||||
/// same way the bindings are, so this carries the newer of those two as well; the relay's flag has its
|
||||
/// own tests.
|
||||
/// pair of pinned paths, a pair of forwards and a Spelunk route — which are orthogonal to the binding and
|
||||
/// are what make this host reach the highest schema version a valid host can. The route and the relay are
|
||||
/// exclusive in the same way the bindings are, so this carries the newer of those two as well; the relay's
|
||||
/// flag has its own tests.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AFullHost_RoundTrips()
|
||||
@@ -40,6 +40,7 @@ public sealed class HostSecretCodecTests
|
||||
groupId: Production,
|
||||
tags: [Pci, EuWest],
|
||||
pinnedPaths: ["/var/www/app", "/var/log"],
|
||||
portForwards: [Postgres, Socks],
|
||||
spelunk: ThroughSpelunk());
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
@@ -59,6 +59,7 @@ public sealed class HostSecretMergeTests
|
||||
GroupId = Production,
|
||||
TagIds = TagSet.Create([Pci]),
|
||||
PinnedPaths = PinnedPathList.Create(["/var/www/app"]),
|
||||
PortForwards = PortForwardList.Create([Postgres]),
|
||||
};
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Text;
|
||||
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
||||
|
||||
namespace DodoSSH.Client.Domain.Tests;
|
||||
|
||||
/// <summary>Port forwards as a host carries them: stored, validated, compared and merged.</summary>
|
||||
public sealed class PortForwardTests
|
||||
{
|
||||
// ---- Encoding ----
|
||||
|
||||
[Fact]
|
||||
public void AHostWithForwards_IsWrittenAtTheirVersion_AndOnlyThen()
|
||||
{
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(portForwards: [Postgres])), out var with)
|
||||
.ShouldBeTrue();
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(portForwards: [])), out var without)
|
||||
.ShouldBeTrue();
|
||||
|
||||
with.ShouldNotBeNull().SchemaVersion.ShouldBe(HostSecretCodec.PortForwardsSchemaVersion);
|
||||
without.ShouldNotBeNull().SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwards_AreWrittenLast_InListOrder_WithTheKindAsItsNumber()
|
||||
{
|
||||
// Last, so every older field keeps its bytes. The kind is a number so that renaming an enum member
|
||||
// can never change what another machine reads.
|
||||
var bytes = HostSecretCodec.Encode(Host(username: null, portForwards: [Socks, Postgres]));
|
||||
|
||||
Encoding.UTF8.GetString(bytes).ShouldBe(
|
||||
"""
|
||||
{"schemaVersion":8,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"portForwards":[{"id":"0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b","kind":2,"bindPort":1080},{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":0,"bindPort":5432,"targetHost":"db.internal","targetPort":5432}]}
|
||||
""");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APayloadFromBeforeForwards_DecodesAsCarryingNone()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"pinnedPaths":["/var/log"]}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().Host.PortForwards.ShouldBe(PortForwardList.Empty);
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"kind":0,"bindPort":5432,"targetHost":"db","targetPort":5432}""")]
|
||||
[InlineData("""{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","bindPort":5432,"targetHost":"db","targetPort":5432}""")]
|
||||
[InlineData("""{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":0,"targetHost":"db","targetPort":5432}""")]
|
||||
[InlineData("""{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":7,"bindPort":5432,"targetHost":"db","targetPort":5432}""")]
|
||||
[InlineData("""{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":0,"bindPort":5432}""")]
|
||||
[InlineData("""{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":2,"bindPort":1080,"targetHost":"db","targetPort":5432}""")]
|
||||
[InlineData("null")]
|
||||
public void AForwardMissingOrContradictingWhatItNeeds_FailsTheDecode(string forward)
|
||||
{
|
||||
// Nothing is filled in with a default: an invented id or kind is a forward the next save writes back
|
||||
// as though somebody had asked for it.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
$$"""{"schemaVersion":8,"label":"prod-db","hostname":"db.internal","port":22,"portForwards":[{{forward}}]}""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APayloadRepeatingAForwardId_DecodesKeepingTheFirst()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":8,"label":"prod-db","hostname":"db.internal","port":22,"portForwards":[{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":0,"bindPort":5432,"targetHost":"db.internal","targetPort":5432},{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":2,"bindPort":1080}]}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().Host.PortForwards.ShouldBe(PortForwardList.Create([Postgres]));
|
||||
}
|
||||
|
||||
// ---- Validation ----
|
||||
|
||||
[Fact]
|
||||
public void AHostCarryingTooManyForwards_IsRefused()
|
||||
{
|
||||
var forwards = Enumerable.Range(0, HostSecret.MaxPortForwards + 1)
|
||||
.Select(i => Postgres with { Id = Guid.CreateVersion7(), BindPort = 10000 + i })
|
||||
.ToArray();
|
||||
|
||||
Host(portForwards: forwards).TryValidate(out var reason).ShouldBeFalse();
|
||||
reason.ShouldBe($"A host cannot carry more than {HostSecret.MaxPortForwards} port forwards.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoForwardsOnOnePort_AreStorable()
|
||||
{
|
||||
// What two machines each adding one produces, and a merge whose result could not be written back
|
||||
// would strand the host. The second listener is what fails, when it starts.
|
||||
var clash = Postgres with { Id = Guid.CreateVersion7(), TargetHost = "replica.internal" };
|
||||
|
||||
Host(portForwards: [Postgres, clash]).TryValidate(out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PortForwardKind.Local, 0, "db", 5432, "A forward's listening port must be between 1 and 65535, not 0.")]
|
||||
[InlineData(PortForwardKind.Local, 5432, " ", 5432, "A forward needs a host to forward to.")]
|
||||
[InlineData(PortForwardKind.Local, 5432, "db internal", 5432, "A forward's target cannot contain spaces or control characters.")]
|
||||
[InlineData(PortForwardKind.Remote, 8080, "localhost", null, "A forward needs a port to forward to.")]
|
||||
[InlineData(PortForwardKind.Remote, 8080, "localhost", 70000, "A forward's target port must be between 1 and 65535, not 70000.")]
|
||||
[InlineData(PortForwardKind.Dynamic, 1080, "db", null, "A dynamic forward has no target: each connection through it names its own.")]
|
||||
public void AnInvalidForward_IsRefusedWithItsReason(
|
||||
PortForwardKind kind,
|
||||
int bindPort,
|
||||
string? targetHost,
|
||||
int? targetPort,
|
||||
string expected)
|
||||
{
|
||||
var forward = new PortForwardRule
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Kind = kind,
|
||||
BindPort = bindPort,
|
||||
TargetHost = targetHost,
|
||||
TargetPort = targetPort,
|
||||
};
|
||||
|
||||
forward.TryValidate(out var reason).ShouldBeFalse();
|
||||
reason.ShouldBe(expected);
|
||||
|
||||
Host(portForwards: [forward]).TryValidate(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnEmptyId_IsRefused()
|
||||
{
|
||||
(Postgres with { Id = Guid.Empty }).TryValidate(out var reason).ShouldBeFalse();
|
||||
reason.ShouldBe("A port forward cannot have an empty id.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PortForwardKind.Local, "-L 5432:db.internal:5432")]
|
||||
[InlineData(PortForwardKind.Remote, "-R 5432:db.internal:5432")]
|
||||
public void Notation_IsOpenSshs(PortForwardKind kind, string expected)
|
||||
{
|
||||
(Postgres with { Kind = kind }).Notation.ShouldBe(expected);
|
||||
Socks.Notation.ShouldBe("-D 1080");
|
||||
}
|
||||
|
||||
// ---- Equality ----
|
||||
|
||||
[Fact]
|
||||
public void AForwardList_ComparesByContentsAndOrder()
|
||||
{
|
||||
PortForwardList.Create([Postgres, Socks]).ShouldBe(PortForwardList.Create([Postgres, Socks]));
|
||||
(PortForwardList.Create([Postgres, Socks]) == PortForwardList.Create([Postgres, Socks])).ShouldBeTrue();
|
||||
PortForwardList.Create([Postgres, Socks]).ShouldNotBe(PortForwardList.Create([Socks, Postgres]));
|
||||
Host(portForwards: [Postgres]).ShouldBe(Host(portForwards: [Postgres]));
|
||||
}
|
||||
|
||||
// ---- Merge ----
|
||||
|
||||
[Fact]
|
||||
public void TwoMachinesEachAddingAForward_BothKeepTheirs()
|
||||
{
|
||||
var ancestor = Host();
|
||||
|
||||
var result = HostSecretMerge.Merge(
|
||||
ancestor,
|
||||
ancestor with { PortForwards = PortForwardList.Create([Postgres]) },
|
||||
ancestor with { PortForwards = PortForwardList.Create([Socks]) });
|
||||
|
||||
result.Merged.PortForwards.ShouldBe(PortForwardList.Create([Postgres, Socks]));
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneForwardEditedDifferentlyOnBothSides_TakesTheServersAndRecordsTheOther()
|
||||
{
|
||||
var ancestor = Host(portForwards: [Postgres]);
|
||||
var local = Postgres with { BindPort = 15432 };
|
||||
var remote = Postgres with { TargetHost = "replica.internal" };
|
||||
|
||||
var result = HostSecretMerge.Merge(
|
||||
ancestor,
|
||||
ancestor with { PortForwards = PortForwardList.Create([local]) },
|
||||
ancestor with { PortForwards = PortForwardList.Create([remote]) });
|
||||
|
||||
result.Merged.PortForwards.ShouldBe(PortForwardList.Create([remote]));
|
||||
|
||||
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
||||
conflict.Field.ShouldBe($"PortForwards[{Postgres.Id}]");
|
||||
conflict.Kept.ShouldBe("-L 5432:replica.internal:5432");
|
||||
conflict.Discarded.ShouldBe("-L 15432:db.internal:5432");
|
||||
conflict.DiscardedWasRemoval.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnEditBeatsARemoval_AndTheRemovalIsRecorded()
|
||||
{
|
||||
var ancestor = Host(portForwards: [Postgres]);
|
||||
var edited = Postgres with { BindPort = 15432 };
|
||||
|
||||
var result = HostSecretMerge.Merge(
|
||||
ancestor,
|
||||
ancestor with { PortForwards = PortForwardList.Empty },
|
||||
ancestor with { PortForwards = PortForwardList.Create([edited]) });
|
||||
|
||||
result.Merged.PortForwards.ShouldBe(PortForwardList.Create([edited]));
|
||||
result.Conflicts.ShouldHaveSingleItem().DiscardedWasRemoval.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARemovalOnOneSideOnly_IsTaken()
|
||||
{
|
||||
var ancestor = Host(portForwards: [Postgres, Socks]);
|
||||
|
||||
var result = HostSecretMerge.Merge(
|
||||
ancestor,
|
||||
ancestor with { PortForwards = PortForwardList.Create([Socks]) },
|
||||
ancestor);
|
||||
|
||||
result.Merged.PortForwards.ShouldBe(PortForwardList.Create([Socks]));
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ public sealed class SpelunkRouteTests
|
||||
|
||||
Encoding.UTF8.GetString(bytes).ShouldBe(
|
||||
"""
|
||||
{"schemaVersion":8,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b","apiKeyCredentialId":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a"}}
|
||||
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"}}
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ public sealed class SpelunkRouteTests
|
||||
// The other reading — no route — would turn a damaged Spelunk host into one dialled directly.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":8,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com"}}
|
||||
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com"}}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
||||
@@ -156,10 +156,10 @@ public sealed class SpelunkRouteTests
|
||||
public void ARouteWrittenByANewerClient_IsReadOnlyHere()
|
||||
{
|
||||
// The rule every version before this one relies on, checked from the far side: a build that knows
|
||||
// version 8 reads a version 9 host, shows it, and refuses to re-encode what it cannot represent.
|
||||
// version 9 reads a version 10 host, shows it, and refuses to re-encode what it cannot represent.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b","apiKeyCredentialId":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a"},"somethingNewer":true}
|
||||
{"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Client.Ssh.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The three kinds of forward, each carrying real bytes through the container's sshd.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The target of a local forward is named as the container sees it</b>, for the reason
|
||||
/// <c>LoopbackProxyTests</c> gives: the remote resolves it, so <c>localhost:2222</c> is the container's own
|
||||
/// sshd, and the published port this test host uses means nothing in there. What comes back is that sshd's
|
||||
/// banner, which is bytes nothing but a real forward could have fetched.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A remote forward is proved the other way round. A listener on this test host answers with a known line,
|
||||
/// the remote is asked to listen for it, and a local forward on the same connection reaches that remote
|
||||
/// port from the inside — so the line has crossed the connection twice, once in each direction.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(SshCollection.Name)]
|
||||
public sealed class PortForwardTests(SshServerFixture fixture)
|
||||
{
|
||||
/// <summary>What the container calls itself. See the class remarks.</summary>
|
||||
private const string InternalHost = "localhost";
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task ALocalForward_CarriesAConnectionToATargetOnlyTheRemoteCanSee()
|
||||
{
|
||||
await using var connection = await ConnectAsync();
|
||||
|
||||
await using var forward = await connection.StartForwardAsync(
|
||||
new SshPortForwardSpec(SshPortForwardKind.Local, 0, InternalHost, SshServerFixture.InternalPort),
|
||||
Token);
|
||||
|
||||
forward.IsListening.ShouldBeTrue();
|
||||
forward.BoundPort.ShouldBeGreaterThan(0, "an ephemeral port is reported back once bound");
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, forward.BoundPort, Token);
|
||||
|
||||
(await ReadLineAsync(client.GetStream())).ShouldStartWith("SSH-2.0-");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADynamicForward_IsASocksProxyWhoseConnectionsLeaveFromTheRemote()
|
||||
{
|
||||
await using var connection = await ConnectAsync();
|
||||
|
||||
await using var forward = await connection.StartForwardAsync(
|
||||
new SshPortForwardSpec(SshPortForwardKind.Dynamic, 0), Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, forward.BoundPort, Token);
|
||||
|
||||
var stream = client.GetStream();
|
||||
await Socks5ConnectAsync(stream, InternalHost, SshServerFixture.InternalPort);
|
||||
|
||||
(await ReadLineAsync(stream)).ShouldStartWith("SSH-2.0-");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARemoteForward_CarriesAConnectionOnTheRemoteBackToThisMachine()
|
||||
{
|
||||
const string Greeting = "hello from the client side";
|
||||
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
var served = ServeOneLineAsync(listener, Greeting);
|
||||
|
||||
await using var connection = await ConnectAsync();
|
||||
|
||||
var remotePort = RandomNumberGenerator.GetInt32(20000, 60000);
|
||||
|
||||
await using var remote = await connection.StartForwardAsync(
|
||||
new SshPortForwardSpec(
|
||||
SshPortForwardKind.Remote,
|
||||
remotePort,
|
||||
"127.0.0.1",
|
||||
((IPEndPoint)listener.LocalEndpoint).Port),
|
||||
Token);
|
||||
|
||||
remote.BoundPort.ShouldBe(remotePort);
|
||||
|
||||
// Reaches the remote's own loopback, which is where the remote forward is listening.
|
||||
await using var inward = await connection.StartForwardAsync(
|
||||
new SshPortForwardSpec(SshPortForwardKind.Local, 0, "127.0.0.1", remotePort),
|
||||
Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, inward.BoundPort, Token);
|
||||
|
||||
(await ReadLineAsync(client.GetStream())).ShouldBe(Greeting);
|
||||
|
||||
await served;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APortAlreadyInUse_IsRefusedWithASentenceNamingIt()
|
||||
{
|
||||
using var squatter = new TcpListener(IPAddress.Loopback, 0);
|
||||
squatter.Start();
|
||||
|
||||
var taken = ((IPEndPoint)squatter.LocalEndpoint).Port;
|
||||
|
||||
await using var connection = await ConnectAsync();
|
||||
|
||||
var refused = await Should.ThrowAsync<SshPortForwardException>(async () =>
|
||||
await connection.StartForwardAsync(
|
||||
new SshPortForwardSpec(SshPortForwardKind.Local, taken, InternalHost, SshServerFixture.InternalPort),
|
||||
Token));
|
||||
|
||||
refused.Message.ShouldBe($"Port {taken} on this machine is already in use.");
|
||||
|
||||
// And the refusal left nothing half-registered on the client: the next forward still starts.
|
||||
await using var next = await connection.StartForwardAsync(
|
||||
new SshPortForwardSpec(SshPortForwardKind.Local, 0, InternalHost, SshServerFixture.InternalPort),
|
||||
Token);
|
||||
|
||||
next.IsListening.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposingAForward_StopsItsListener()
|
||||
{
|
||||
await using var connection = await ConnectAsync();
|
||||
|
||||
var forward = await connection.StartForwardAsync(
|
||||
new SshPortForwardSpec(SshPortForwardKind.Local, 0, InternalHost, SshServerFixture.InternalPort),
|
||||
Token);
|
||||
|
||||
var port = forward.BoundPort;
|
||||
|
||||
await forward.DisposeAsync();
|
||||
|
||||
forward.IsListening.ShouldBeFalse();
|
||||
await ShouldRefuseAsync(port);
|
||||
|
||||
// Idempotent, because the connection closing will try again.
|
||||
await forward.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClosingTheConnection_StopsEveryForwardOnIt()
|
||||
{
|
||||
var connection = await ConnectAsync();
|
||||
|
||||
var forward = await connection.StartForwardAsync(
|
||||
new SshPortForwardSpec(SshPortForwardKind.Dynamic, 0), Token);
|
||||
|
||||
var port = forward.BoundPort;
|
||||
|
||||
await connection.DisposeAsync();
|
||||
|
||||
forward.IsListening.ShouldBeFalse();
|
||||
await ShouldRefuseAsync(port);
|
||||
|
||||
// What the workspace does after closing a session, and it must be harmless.
|
||||
await forward.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnInvalidSpec_IsRefusedBeforeAnythingListens()
|
||||
{
|
||||
await using var connection = await ConnectAsync();
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(async () =>
|
||||
await connection.StartForwardAsync(
|
||||
new SshPortForwardSpec(SshPortForwardKind.Local, 0, TargetHost: null, TargetPort: 0),
|
||||
Token));
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(async () =>
|
||||
await connection.StartForwardAsync(
|
||||
new SshPortForwardSpec(SshPortForwardKind.Remote, 0, "127.0.0.1", 80),
|
||||
Token));
|
||||
}
|
||||
|
||||
private static async Task ShouldRefuseAsync(int port)
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
|
||||
await Should.ThrowAsync<SocketException>(async () =>
|
||||
await client.ConnectAsync(IPAddress.Loopback, port, Token));
|
||||
}
|
||||
|
||||
private static async Task ServeOneLineAsync(TcpListener listener, string line)
|
||||
{
|
||||
using var accepted = await listener.AcceptTcpClientAsync(Token);
|
||||
|
||||
await accepted.GetStream().WriteAsync(Encoding.ASCII.GetBytes(line + "\n"), Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The smallest SOCKS5 exchange there is: no authentication, then CONNECT by name.
|
||||
/// </summary>
|
||||
private static async Task Socks5ConnectAsync(NetworkStream stream, string host, int port)
|
||||
{
|
||||
await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token);
|
||||
|
||||
var method = new byte[2];
|
||||
await stream.ReadExactlyAsync(method, Token);
|
||||
method.ShouldBe(new byte[] { 5, 0 });
|
||||
|
||||
var name = Encoding.ASCII.GetBytes(host);
|
||||
byte[] request = [5, 1, 0, 3, (byte)name.Length, .. name, (byte)(port >> 8), (byte)port];
|
||||
await stream.WriteAsync(request, Token);
|
||||
|
||||
// An IPv4 bound address, which is what SSH.NET always answers with: ten bytes.
|
||||
var reply = new byte[10];
|
||||
await stream.ReadExactlyAsync(reply, Token);
|
||||
reply[1].ShouldBe((byte)0, "the proxy should report the CONNECT as granted");
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(NetworkStream stream)
|
||||
{
|
||||
var line = new List<byte>();
|
||||
var one = new byte[1];
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
while (await stream.ReadAsync(one, timeout.Token) == 1 && one[0] != (byte)'\n')
|
||||
{
|
||||
line.Add(one[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. line]).TrimEnd('\r');
|
||||
}
|
||||
|
||||
private SshConnectionRequest Request() =>
|
||||
new(fixture.Host, fixture.Port, SshServerFixture.Username, new SshPasswordCredential(SshServerFixture.Password));
|
||||
|
||||
/// <summary>Connects with the container's host key already trusted, since first contact is not the subject.</summary>
|
||||
private async Task<ISshConnection> ConnectAsync()
|
||||
{
|
||||
var knownHosts = new InMemoryKnownHostStore();
|
||||
var factory = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await factory.ConnectAsync(Request(), progress: null, Token));
|
||||
|
||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||
|
||||
return await factory.ConnectAsync(Request(), progress: null, Token);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Sync.Tests;
|
||||
@@ -53,4 +54,23 @@ public sealed class ItemKindsTests
|
||||
|
||||
reconcilers.Keys.Order().ShouldBe(ItemKinds.SyncedTypes.Order());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostEditThatOnlyChangedItsPins_IsReportedAsChangingPinnedPaths()
|
||||
{
|
||||
// Pinned paths were the one host field Changes did not look at, so an edit that only pinned or
|
||||
// unpinned a directory reached the keychain log as an update that changed nothing.
|
||||
var before = new HostSecret
|
||||
{
|
||||
Label = "db-01",
|
||||
Hostname = "db-01.internal",
|
||||
PinnedPaths = PinnedPathList.Create(["/var/log"]),
|
||||
};
|
||||
var after = before with { PinnedPaths = PinnedPathList.Create(["/var/log", "/etc/nginx"]) };
|
||||
|
||||
HostKind.Instance.Changes(before, after).ShouldBe(["Pinned paths"]);
|
||||
HostKind.Instance.Changes(before, before with { PinnedPaths = PinnedPathList.Empty })
|
||||
.ShouldBe(["Pinned paths"]);
|
||||
HostKind.Instance.Changes(before, before with { }).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,17 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
|
||||
return Task.FromResult<ISshShellSession>(Shell);
|
||||
}
|
||||
|
||||
/// <summary>The forwards started on this connection, in order.</summary>
|
||||
internal List<SshPortForwardSpec> Forwards { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshPortForward> StartForwardAsync(SshPortForwardSpec spec, CancellationToken cancellationToken)
|
||||
{
|
||||
Forwards.Add(spec);
|
||||
|
||||
return Task.FromResult<ISshPortForward>(new FakePortForward(spec));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -361,3 +372,33 @@ internal sealed class DelegateProgress<T>(Action<T> report) : IProgress<T>
|
||||
{
|
||||
public void Report(T value) => report(value);
|
||||
}
|
||||
|
||||
/// <summary>A forward that listens on nothing and says it is listening until disposed.</summary>
|
||||
internal sealed class FakePortForward(SshPortForwardSpec spec) : ISshPortForward
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<SshPortForwardErrorEventArgs>? ErrorOccurred
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SshPortForwardSpec Spec { get; } = spec;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int BoundPort => Spec.BindPort;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsListening { get; private set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? LastError => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IsListening = false;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -599,6 +599,53 @@ public sealed class TerminalWorkspaceTests
|
||||
await WaitUntilAsync(() => Volatile.Read(ref reattachedCount) > 0);
|
||||
}
|
||||
|
||||
// ---- Port forwards ----
|
||||
|
||||
[Fact]
|
||||
public async Task AForward_RunsOverTheConnectionOfTheSessionItNames()
|
||||
{
|
||||
var connections = new FakeConnectionFactory(blockShellReads: true);
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
var second = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
var spec = new SshPortForwardSpec(SshPortForwardKind.Local, 5432, "db.internal", 5432);
|
||||
|
||||
await using var forward = await workspace.StartForwardAsync(
|
||||
second, spec, TestContext.Current.CancellationToken);
|
||||
|
||||
forward.Spec.ShouldBe(spec);
|
||||
connections.Connections[0].Forwards.ShouldBeEmpty();
|
||||
connections.Connections[1].Forwards.ShouldBe([spec]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AForwardOnASessionThatIsNotRunning_IsRefused()
|
||||
{
|
||||
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var ended = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await WaitUntilAsync(() => !workspace.IsSessionLive(ended));
|
||||
|
||||
var spec = new SshPortForwardSpec(SshPortForwardKind.Dynamic, 1080);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await workspace.StartForwardAsync(ended, spec, TestContext.Current.CancellationToken));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await workspace.StartForwardAsync(999, spec, TestContext.Current.CancellationToken));
|
||||
|
||||
connections.Connections[0].Forwards.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <remarks>
|
||||
|
||||
Reference in new issue
Block a user