Public Access
Add port forwarding: local, remote and dynamic, per terminal session
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:
1 parent
c8848ec1e3
commit
f05deb4fbc
36 files changed
+2776
-42
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user