Public Access
A host can now carry port forwards (-L, -R, -D), saved inside its encrypted payload as RelayTicketRequest.PortForwardId already assumed, so they sync and merge per forward like tags and pinned paths. The host schema moves to version 8 only for hosts that carry forwards. ISshConnection gains StartForwardAsync over SSH.NET's forwarded ports. Every listener binds loopback and there is no bind-address field: a forward is something any member of a shared keychain can edit. A remote forward's target is resolved here, IPv4 first, rather than by SSH.NET's constructor, which takes the first DNS answer and can send ::1. Forwards start with every terminal opened to the host and stop with its connection; a reconnect starts them again. The session sidebar lists them with state, failure reason and an on/off switch, the status bar counts the listening ones, and the host editor and detail pane gain a PORT FORWARDS section. A forward that cannot start fails alone. The phone does not start forwards yet: it has no surface showing what is listening, and its More screen now says so. No byte counts, since SSH.NET keeps none.
226 lines
9.5 KiB
C#
226 lines
9.5 KiB
C#
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();
|
|
}
|
|
}
|