Files
DodoSSH/src/DodoSSH.Client.Domain/HostSecretMerge.cs
T
jaap-jan f05deb4fbc
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
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.
2026-09-28 20:47:02 +02:00

390 lines
16 KiB
C#

using System.Globalization;
namespace DodoSSH.Client.Domain;
/// <summary>
/// A value the merge had to override, kept so the user can see it and put it back.
/// </summary>
/// <remarks>
/// This record is the reason the merge is allowed to pick a winner at all. Choosing a side is only
/// acceptable because the other side is preserved verbatim and surfaced; without that, a
/// field-level merge is just last-writer-wins with extra steps.
/// </remarks>
/// <param name="Field">
/// Which field, as a path. A directive reads <c>Options[ServerAliveInterval]</c> so the user is told
/// which one rather than merely that "options" changed.
/// </param>
/// <param name="DiscardedSide">Whose intent was overridden.</param>
/// <param name="Kept">The value that survives, rendered for display.</param>
/// <param name="Discarded">The value that lost, rendered for display.</param>
/// <param name="DiscardedWasRemoval">
/// True when what lost was a deletion rather than a different value.
/// </param>
public sealed record HostFieldConflict(
string Field,
MergeSide DiscardedSide,
string? Kept,
string? Discarded,
bool DiscardedWasRemoval);
/// <summary>The merged host, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The host to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record HostMergeResult(
HostSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <summary>
/// Merges two divergent versions of a host against the version they both started from.
/// </summary>
/// <remarks>
/// <para>
/// Called when a pull brings down a change to an item that also has a local edit pending, and again
/// when a push comes back <c>Conflict</c> carrying the server's current row. Both paths need the
/// same answer, so both go through here.
/// </para>
/// <para>
/// Scalar fields defer to the server on a genuine clash and the jump chain merges as a whole value,
/// because its order is its meaning. Directives merge per name, which is what lets two people each
/// add one and both keep it. See <see cref="ThreeWayMerge"/> for why the remote side wins.
/// </para>
/// </remarks>
public static class HostSecretMerge
{
/// <summary>
/// Produces the merged host.
/// </summary>
/// <param name="ancestor">
/// The version both sides branched from — the ciphertext the client retained when it queued its
/// local edit. Without it this degrades to a two-way diff, which cannot tell an edit from a
/// revert and so cannot avoid resurrecting deleted values.
/// </param>
/// <param name="local">The pending local version.</param>
/// <param name="remote">The server's current version.</param>
public static HostMergeResult Merge(HostSecret ancestor, HostSecret local, HostSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
var conflicts = new List<HostFieldConflict>();
var merged = new HostSecret
{
Label = Text(nameof(HostSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts),
Hostname = Text(
nameof(HostSecret.Hostname), ancestor.Hostname, local.Hostname, remote.Hostname, conflicts),
Port = Field(
nameof(HostSecret.Port),
ancestor.Port,
local.Port,
remote.Port,
conflicts,
static port => port?.ToString(CultureInfo.InvariantCulture) ?? "the group's port"),
Username = Text(
nameof(HostSecret.Username), ancestor.Username, local.Username, remote.Username, conflicts),
Notes = Text(nameof(HostSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
JumpHostIds = Field(
nameof(HostSecret.JumpHostIds),
ancestor.JumpHostIds,
local.JumpHostIds,
remote.JumpHostIds,
conflicts,
FormatChain),
Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts),
TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts),
PinnedPaths = MergePinnedPaths(
ancestor.PinnedPaths, local.PinnedPaths, remote.PinnedPaths, conflicts),
PortForwards = MergePortForwards(
ancestor.PortForwards, local.PortForwards, remote.PortForwards, conflicts),
RelayEnabled = Field(
nameof(HostSecret.RelayEnabled),
ancestor.RelayEnabled,
local.RelayEnabled,
remote.RelayEnabled,
conflicts,
static enabled => enabled ? "enabled" : "disabled"),
};
return new HostMergeResult(
WithReferences(merged, ancestor, local, remote, conflicts), conflicts);
}
/// <summary>
/// Merges the three ids a host can point at: its key, its credential and its group.
/// </summary>
/// <remarks>
/// <para>
/// Split out for length, and they do belong together: each is a reference to another vault item, each
/// merges as a plain scalar, and each can end up dangling because the item it names may be deleted on
/// another machine. None of that is the merge's problem — it is handled where the reference is used.
/// </para>
/// <para>
/// <b>The ids are shown in a clash rather than redacted.</b> An id is not a secret — it names a vault
/// item, it is not the key — and hiding it would leave the user unable to tell which of two keys the
/// merge dropped.
/// </para>
/// </remarks>
private static HostSecret WithReferences(
HostSecret merged,
HostSecret ancestor,
HostSecret local,
HostSecret remote,
List<HostFieldConflict> conflicts) =>
merged with
{
SshKeyId = Field(
nameof(HostSecret.SshKeyId),
ancestor.SshKeyId,
local.SshKeyId,
remote.SshKeyId,
conflicts,
static id => id?.ToString() ?? "no key"),
CredentialId = Field(
nameof(HostSecret.CredentialId),
ancestor.CredentialId,
local.CredentialId,
remote.CredentialId,
conflicts,
static id => id?.ToString() ?? "no credential"),
GroupId = Field(
nameof(HostSecret.GroupId),
ancestor.GroupId,
local.GroupId,
remote.GroupId,
conflicts,
static id => id?.ToString() ?? "ungrouped"),
AsksForPassword = Field(
nameof(HostSecret.AsksForPassword),
ancestor.AsksForPassword,
local.AsksForPassword,
remote.AsksForPassword,
conflicts,
static asked => asked is true ? "a typed password" : "the group's binding"),
};
private static string Text(
string name,
string? ancestor,
string? local,
string? remote,
List<HostFieldConflict> conflicts) =>
Field(name, ancestor, local, remote, conflicts, static value => value, StringComparer.Ordinal)!;
/// <remarks>
/// <para>
/// A scalar clash always overrides the local side — see <see cref="ThreeWayMerge"/> — so the
/// discarded side is fixed here rather than derived.
/// </para>
/// <para>
/// The formatter is handed the discarded value even when that value is null, and the null-forgiving
/// operator says why that is safe: a conflicted merge always has a discarded value, so a null here is a
/// nullable field whose discarded value was "unset" rather than a missing one. Short-circuiting on null
/// instead — which this did — meant the formatter never ran for exactly that case, so a field whose
/// absence has a name could not report it and the conflict log showed an empty string in its place.
/// </para>
/// </remarks>
private static T Field<T>(
string name,
T ancestor,
T local,
T remote,
List<HostFieldConflict> conflicts,
Func<T, string?> format,
IEqualityComparer<T>? comparer = null)
{
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, comparer);
if (merge.IsConflicted)
{
conflicts.Add(new HostFieldConflict(
name,
MergeSide.Local,
format(merge.Value),
format(merge.Discarded!),
DiscardedWasRemoval: false));
}
return merge.Value;
}
private static HostOptions MergeOptions(
HostOptions ancestor,
HostOptions local,
HostOptions remote,
List<HostFieldConflict> conflicts)
{
var merge = ThreeWayMerge.Map(
ancestor.ToNameMap(),
local.ToNameMap(),
remote.ToNameMap(),
HostOption.NameComparer,
StringComparer.Ordinal);
foreach (var conflict in merge.Conflicts)
{
conflicts.Add(new HostFieldConflict(
$"{nameof(HostSecret.Options)}[{conflict.Key}]",
conflict.DiscardedSide,
conflict.Kept,
conflict.Discarded,
conflict.DiscardedWasRemoval));
}
// The merged map is keyed by the same comparer, so uniqueness already holds and Create
// cannot throw here.
return HostOptions.Create(
merge.Merged.Select(entry => new HostOption(entry.Key, entry.Value)));
}
/// <summary>
/// Merges the tag set per tag, so two people each adding a different one both keep theirs.
/// </summary>
/// <remarks>
/// <para>
/// The reason <see cref="TagSet"/> exposes a map at all. A whole-value merge here would take one side's
/// set entire and drop the other's — so a colleague tagging a host "pci" while you tagged it "eu-west"
/// would silently lose one of the two, which is the single most visible difference between a field-level
/// merge and last-writer-wins.
/// </para>
/// <para>
/// <b>No conflict is reachable, and the loop below is kept anyway.</b> The value in the map is the key,
/// so a tag can only be present or absent — and running that through
/// <see cref="ThreeWayMerge.Map"/> leaves nothing to disagree about. Where both sides hold the tag or
/// neither does they agree; where exactly one side moved, the other still matches the ancestor and the
/// move is taken. The "both sides moved differently" branch needs one key to hold two values, which this
/// map cannot express. A set is the one collection shape that merges without ever asking the user.
/// </para>
/// <para>
/// The loop stays because that proof depends on <see cref="TagSet.ToIdMap"/> keying by the value, which
/// is one edit away from ceasing to be true — and the failure it would cause is a discarded tag that
/// nothing records. Unreachable code that costs a <c>foreach</c> over an empty list is a cheaper way to
/// hold that invariant than a comment alone.
/// </para>
/// </remarks>
private static TagSet MergeTags(
TagSet ancestor,
TagSet local,
TagSet remote,
List<HostFieldConflict> conflicts)
{
var merge = ThreeWayMerge.Map(
ancestor.ToIdMap(),
local.ToIdMap(),
remote.ToIdMap(),
EqualityComparer<Guid>.Default);
foreach (var conflict in merge.Conflicts)
{
conflicts.Add(new HostFieldConflict(
$"{nameof(HostSecret.TagIds)}[{conflict.Key}]",
conflict.DiscardedSide,
conflict.Kept == Guid.Empty ? "not tagged" : "tagged",
conflict.DiscardedWasRemoval ? "not tagged" : "tagged",
conflict.DiscardedWasRemoval));
}
return TagSet.Create(merge.Merged.Keys);
}
/// <summary>
/// Merges the pinned paths per path, so two people each pinning a different one both keep theirs.
/// </summary>
/// <remarks>
/// <para>
/// The same reasoning as <see cref="MergeTags"/>, and the same shape: a whole-value merge would take
/// one side's list entire and drop the other's, so a colleague pinning <c>/var/log</c> while you pinned
/// <c>/var/www/app</c> would silently lose one of the two.
/// </para>
/// <para>
/// <b>Unlike a tag set, order survives.</b> <see cref="PinnedPathList.ToPathMap"/> is built in list
/// order rather than a canonical one, and <see cref="ThreeWayMerge.Map"/> unions keys ancestor first,
/// then local, then remote — so the merged keys come out base order, then this machine's additions,
/// then the server's, which is what <see cref="PinnedPathList.Create(IEnumerable{string})"/> then
/// preserves rather than re-sorting.
/// </para>
/// <para>
/// <b>No conflict is reachable here either, for the reason <see cref="MergeTags"/> gives.</b> The value
/// in the map is the key, so a path can only be present or absent, and the "both sides moved
/// differently" branch of the keyed merge needs one key to hold two values — which this map cannot
/// express. The loop stays for the same reason it stays there: the proof depends on
/// <see cref="PinnedPathList.ToPathMap"/> keying by value, and that is one edit away from no longer
/// being true.
/// </para>
/// </remarks>
private static PinnedPathList MergePinnedPaths(
PinnedPathList ancestor,
PinnedPathList local,
PinnedPathList remote,
List<HostFieldConflict> conflicts)
{
var merge = ThreeWayMerge.Map(
ancestor.ToPathMap(),
local.ToPathMap(),
remote.ToPathMap(),
StringComparer.Ordinal);
foreach (var conflict in merge.Conflicts)
{
conflicts.Add(new HostFieldConflict(
$"{nameof(HostSecret.PinnedPaths)}[{conflict.Key}]",
conflict.DiscardedSide,
conflict.Kept is null ? "not pinned" : "pinned",
conflict.DiscardedWasRemoval ? "not pinned" : "pinned",
conflict.DiscardedWasRemoval));
}
return PinnedPathList.Create(merge.Merged.Keys);
}
/// <summary>
/// Merges the forwards per forward, so two people each adding one both keep theirs.
/// </summary>
/// <remarks>
/// <para>
/// Keyed by id and ordered the way <see cref="MergePinnedPaths"/> is: existing forwards, then this
/// machine's additions, then the server's.
/// </para>
/// <para>
/// <b>Unlike a tag or a pinned path, a conflict is reachable here.</b> The value under a key is the whole
/// rule, so two machines changing one forward differently is a genuine clash. The server's version wins,
/// as every scalar's does, and the other is recorded in OpenSSH's notation so the user can read what was
/// lost. An edit on one side beats a removal on the other, which is the map merge's standing rule.
/// </para>
/// </remarks>
private static PortForwardList MergePortForwards(
PortForwardList ancestor,
PortForwardList local,
PortForwardList remote,
List<HostFieldConflict> conflicts)
{
var merge = ThreeWayMerge.Map(
ancestor.ToIdMap(),
local.ToIdMap(),
remote.ToIdMap(),
EqualityComparer<Guid>.Default);
foreach (var conflict in merge.Conflicts)
{
conflicts.Add(new HostFieldConflict(
$"{nameof(HostSecret.PortForwards)}[{conflict.Key}]",
conflict.DiscardedSide,
conflict.Kept?.Notation ?? "removed",
conflict.Discarded?.Notation ?? "removed",
conflict.DiscardedWasRemoval));
}
return PortForwardList.Create(merge.Merged.Values);
}
private static string FormatChain(JumpChain chain) =>
chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
}