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.
141 lines
4.7 KiB
C#
141 lines
4.7 KiB
C#
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();
|
|
}
|
|
}
|