Add port forwarding: local, remote and dynamic, per terminal session
ci / build and test (push) Successful in 3m43s
ci / api image (push) Canceled after 0s
ci / android head (push) Canceled after 4m6s
ci / desktop nightly (push) Canceled after 21s

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.
This commit is contained in:
jaap-jan committed 2026-09-28 20:47:02 +02:00
1 parent c8848ec1e3
commit f05deb4fbc
36 files changed
+2776 -42

No files matched your search

@@ -21,6 +21,24 @@ 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,
};
/// <remarks>
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
/// about ports gets the host shape that existed before inheritance did — which is what nearly every
@@ -40,7 +58,8 @@ internal static class HostFactory
bool? asksForPassword = null,
Guid? groupId = null,
Guid[]? tags = null,
string[]? pinnedPaths = null) =>
string[]? pinnedPaths = null,
PortForwardRule[]? portForwards = null) =>
new()
{
Label = label,
@@ -59,6 +78,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),
};
/// <summary>
@@ -17,9 +17,9 @@ public sealed class HostSecretCodecTests
/// <remarks>
/// "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 and a
/// pair of pinned paths — which are orthogonal to the binding and are what make this host reach the
/// highest schema version a valid host can.
/// 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 pair of forwards — which are orthogonal to the binding and are what make
/// this host reach the highest schema version a valid host can.
/// </remarks>
[Fact]
public void AFullHost_RoundTrips()
@@ -38,7 +38,8 @@ public sealed class HostSecretCodecTests
credentialId: credentialId,
groupId: Production,
tags: [Pci, EuWest],
pinnedPaths: ["/var/www/app", "/var/log"]);
pinnedPaths: ["/var/www/app", "/var/log"],
portForwards: [Postgres, Socks]);
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();
}
}