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

+94 -1
View File
@@ -112,12 +112,19 @@ public static class HostSecretCodec
/// </remarks>
public const int PinnedPathsSchemaVersion = 7;
/// <summary>The version that introduced <see cref="HostSecret.PortForwards"/>.</summary>
/// <remarks>
/// Its own version for the reason <see cref="PinnedPathsSchemaVersion"/> gives: a host can carry
/// forwards without pinning anything, and should stay editable on a build that knows 7 until it does.
/// </remarks>
public const int PortForwardsSchemaVersion = 8;
/// <summary>The highest schema version this build can write.</summary>
/// <remarks>
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
/// maximum. A new field added below this line has to be named here too.
/// </remarks>
public const int CurrentSchemaVersion = PinnedPathsSchemaVersion;
public const int CurrentSchemaVersion = PortForwardsSchemaVersion;
/// <summary>Serialises a host to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
@@ -163,6 +170,11 @@ public static class HostSecretCodec
// Last, and null for the same reason TagIds is: a host that pins nothing must encode exactly
// as it did before this field existed.
PinnedPaths = host.PinnedPaths.Count == 0 ? null : [.. host.PinnedPaths],
// Last, and null when empty, for the reason both fields above give.
PortForwards = host.PortForwards.Count == 0
? null
: [.. host.PortForwards.Select(PortForwardDocument.From)],
};
return JsonSerializer.SerializeToUtf8Bytes(
@@ -238,6 +250,11 @@ public static class HostSecretCodec
version = Math.Max(version, PinnedPathsSchemaVersion);
}
if (host.PortForwards.Count > 0)
{
version = Math.Max(version, PortForwardsSchemaVersion);
}
return version;
}
@@ -295,6 +312,11 @@ public static class HostSecretCodec
return false;
}
if (!TryBuildForwards(parsed.PortForwards, out var forwards))
{
return false;
}
var candidate = new HostSecret
{
Label = parsed.Label ?? string.Empty,
@@ -316,6 +338,8 @@ public static class HostSecretCodec
TagIds = TagSet.Create(parsed.TagIds ?? []),
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
PortForwards = forwards,
};
if (!candidate.TryValidate(out _))
@@ -326,6 +350,45 @@ public static class HostSecretCodec
host = candidate;
return true;
}
/// <remarks>
/// A missing id, kind or port is a refusal rather than a default. Each would be invented here — a fresh
/// id the other machines do not have, a <c>Local</c> nobody chose — and an invented forward is one the
/// next save writes back as though somebody had asked for it.
/// </remarks>
private static bool TryBuildForwards(
PortForwardDocument?[]? parsed,
[NotNullWhen(true)] out PortForwardList? forwards)
{
forwards = PortForwardList.Empty;
if (parsed is null)
{
return true;
}
var rules = new List<PortForwardRule>(parsed.Length);
foreach (var entry in parsed)
{
if (entry?.Id is not { } id || entry.Kind is not { } kind || entry.BindPort is not { } bindPort)
{
return false;
}
rules.Add(new PortForwardRule
{
Id = id,
Kind = (PortForwardKind)kind,
BindPort = bindPort,
TargetHost = entry.TargetHost,
TargetPort = entry.TargetPort,
});
}
forwards = PortForwardList.Create(rules);
return true;
}
}
/// <summary>
@@ -396,6 +459,36 @@ internal sealed class HostPayloadDocument
/// newer of the two and follows the same rule.
/// </remarks>
public string[]? PinnedPaths { get; set; }
/// <remarks>Last, and null rather than <c>[]</c>, for the reasons <see cref="TagIds"/> gives.</remarks>
public PortForwardDocument?[]? PortForwards { get; set; }
}
/// <summary>One forward's serialised shape. Nullable throughout, because it models untrusted input.</summary>
/// <remarks>
/// The kind is written as its number rather than its name, so renaming a member of
/// <see cref="PortForwardKind"/> can never change what is on the wire.
/// </remarks>
internal sealed class PortForwardDocument
{
public Guid? Id { get; set; }
public int? Kind { get; set; }
public int? BindPort { get; set; }
public string? TargetHost { get; set; }
public int? TargetPort { get; set; }
public static PortForwardDocument From(PortForwardRule rule) => new()
{
Id = rule.Id,
Kind = (int)rule.Kind,
BindPort = rule.BindPort,
TargetHost = rule.TargetHost,
TargetPort = rule.TargetPort,
};
}
[JsonSourceGenerationOptions(