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.
390 lines
16 KiB
C#
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);
|
|
}
|