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

@@ -0,0 +1,140 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// The port forwards saved on a host, in the order the user added them.
/// </summary>
/// <remarks>
/// <para>
/// A dedicated type rather than a list, for the reason <see cref="PinnedPathList"/> gives: a plain list on a
/// record compares by reference, so every host would read as changed on every sync pass.
/// </para>
/// <para>
/// <b>Ordered and unique by id.</b> Order is how the session sidebar lists them. Uniqueness is by
/// <see cref="PortForwardRule.Id"/> rather than by value, keeping the first occurrence, because the id is
/// what the merge keys on — two entries with one id would be one forward the merge could not tell apart.
/// </para>
/// <para>
/// Invalid rules are not rejected at construction, for the reason every collection in this assembly gives:
/// they arrive from another client's payload, and a throw here would fail the whole sync pass.
/// <see cref="HostSecret.TryValidate"/> catches them.
/// </para>
/// </remarks>
public sealed class PortForwardList : IReadOnlyList<PortForwardRule>, IEquatable<PortForwardList>
{
private readonly PortForwardRule[] rules;
private readonly int hash;
private PortForwardList(PortForwardRule[] rules)
{
this.rules = rules;
hash = ComputeHash(rules);
}
/// <summary>No forwards.</summary>
public static PortForwardList Empty { get; } = new([]);
/// <inheritdoc />
public int Count => rules.Length;
/// <inheritdoc />
public PortForwardRule this[int index] => rules[index];
/// <summary>Copies a sequence of rules, keeping order and the first rule with any given id.</summary>
public static PortForwardList Create(IEnumerable<PortForwardRule> rules)
{
ArgumentNullException.ThrowIfNull(rules);
var kept = new List<PortForwardRule>();
var seen = new HashSet<Guid>();
foreach (var rule in rules)
{
if (rule is null)
{
throw new ArgumentException("A forward list cannot contain a null rule.", nameof(rules));
}
if (seen.Add(rule.Id))
{
kept.Add(rule);
}
}
return kept.Count == 0 ? Empty : new PortForwardList([.. kept]);
}
/// <summary>
/// The list as a map from id to rule, in list order, which is the shape <see cref="ThreeWayMerge.Map"/>
/// takes.
/// </summary>
/// <remarks>
/// Built in list order for the reason <see cref="PinnedPathList.ToPathMap"/> is: the merge unions keys
/// ancestor first, then local, then remote, so an ordered map is what keeps "existing forwards, then
/// additions" without the merge ordering anything itself. Unlike a pinned path, a value here can change
/// under its key, so a conflict is reachable: both machines editing the same forward differently.
/// </remarks>
public IReadOnlyDictionary<Guid, PortForwardRule> ToIdMap()
{
var map = new Dictionary<Guid, PortForwardRule>(rules.Length);
foreach (var rule in rules)
{
map[rule.Id] = rule;
}
return map;
}
/// <inheritdoc />
public bool Equals(PortForwardList? other)
{
if (ReferenceEquals(this, other))
{
return true;
}
return other is not null
&& other.hash == hash
&& rules.AsSpan().SequenceEqual(other.rules);
}
/// <inheritdoc />
public override bool Equals(object? obj) => Equals(obj as PortForwardList);
/// <inheritdoc />
public override int GetHashCode() => hash;
/// <inheritdoc />
public IEnumerator<PortForwardRule> GetEnumerator() => ((IEnumerable<PortForwardRule>)rules).GetEnumerator();
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator() => rules.GetEnumerator();
/// <summary>Contents equality, tolerating nulls on either side.</summary>
[SuppressMessage(
"Usage",
"CA2225:Operator overloads have named alternates",
Justification = "Equals(PortForwardList) is the named alternate.")]
public static bool operator ==(PortForwardList? left, PortForwardList? right) =>
left is null ? right is null : left.Equals(right);
/// <summary>Contents inequality.</summary>
public static bool operator !=(PortForwardList? left, PortForwardList? right) => !(left == right);
private static int ComputeHash(PortForwardRule[] rules)
{
// Order-sensitive, because order is kept.
var accumulator = new HashCode();
accumulator.Add(rules.Length);
foreach (var rule in rules)
{
accumulator.Add(rule);
}
return accumulator.ToHashCode();
}
}