Files
DodoSSH/tests/DodoSSH.Client.App.Tests/SingleInstanceTests.cs
T
jaap-jan 6e39807cc0 Reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the
SSH server on the machine's own loopback, so the server needs no open SSH
port. Shells and file transfers both go this way.

Client, in Client.Session:

- SpelunkTunnel implements Spelunk's contract: POST
  {api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
  and {"port":N}, then a WebSocket to the API's own origin at the path
  Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
  upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
  is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
  agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
  could not reach the port, or is too old: "Unknown stream kind") and 4503
  (agent disconnected). Only a path is taken from the ticket response, so it
  cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
  so the connect path has one shape for both.

Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.

The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.

Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.

UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.

dodossh://spelunk/connect?api=&server=&name=&host= links:

- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
  running passes it down the doorbell as "dodossh.open-link <link>", which
  the running instance parses again, bounded, and otherwise ignores whole.
  The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
  otherwise the editor opens on a new, unsaved Spelunk host, with the key
  pre-chosen only when another host already sends it to that Spelunk. A link
  that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
  hooks and removes it on uninstall only if it still points at this
  executable; the macOS plist template declares CFBundleURLTypes and the app
  takes the URL from Avalonia's activation event; the Linux desktop entry
  gets %u and MimeType=x-scheme-handler/dodossh.
2026-09-28 21:25:04 +02:00

350 lines
14 KiB
C#

using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// One desktop instance per profile, and what the second launch does instead of starting.
/// </summary>
/// <remarks>
/// <para>
/// Both halves are real here: a real lock file in a real temporary profile, and a real named pipe. Neither
/// is worth faking — the whole claim being made is about what the operating system does with an exclusive
/// handle and a pipe name, and a fake of either would be a test of the fake.
/// </para>
/// <para>
/// What is <em>not</em> reachable from here is a second process. Every test below runs in one, so the lock
/// is proved against a second <em>handle</em> rather than a second executable, and the window that comes
/// forward is a delegate rather than a window. <c>docs/manual-checks.md</c> 16.11 is the other side of that
/// line.
/// </para>
/// </remarks>
public sealed class SingleInstanceTests : IDisposable
{
/// <summary>
/// A profile of its own per test, which is what makes these safe to run beside a real installation.
/// </summary>
/// <remarks>
/// The pipe name is derived from this directory — see <c>SingleInstance.PipeNameFor</c> — so a unique
/// directory is also a unique pipe. Without that, a test here would ring the doorbell of whatever
/// DodoSSH the developer happens to have running.
/// </remarks>
private readonly ClientPaths paths =
new(Path.Combine(Path.GetTempPath(), $"dodossh-instance-{Guid.CreateVersion7():N}"));
/// <summary>
/// No patience at all, which is what every test here wants.
/// </summary>
/// <remarks>
/// The shipped three seconds exist for a lock held by a process on its way out — an update relaunching
/// itself, above all — and nothing here is on its way anywhere. Passing zero takes the give-up decision
/// on the first attempt, so a refusal costs a millisecond rather than three seconds of the suite.
/// </remarks>
private static readonly TimeSpan Impatient = TimeSpan.Zero;
/// <summary>
/// How long a doorbell is given to ring before the test says it never will.
/// </summary>
/// <remarks>
/// ◆ <b>A bound at all, which is the lesson rather than the number.</b> These two awaits were written
/// against <c>TestContext.Current.CancellationToken</c> alone, which carries no timeout — so the run
/// where a ring was genuinely lost did not fail, it <em>hung</em>, and took the whole CI job with it for
/// as long as anybody let it. Twenty seconds is a hundred times what a local pipe needs and a fifth of
/// what it costs to notice; a bound that is never reached costs nothing and a missing one costs the
/// build.
/// </remarks>
private static readonly TimeSpan LongEnoughForAPipe = TimeSpan.FromSeconds(20);
/// <inheritdoc />
public void Dispose()
{
if (Directory.Exists(paths.DataDirectory))
{
Directory.Delete(paths.DataDirectory, recursive: true);
}
}
[Fact]
public void TheFirstLaunchClaimsTheProfile()
{
using var claim = SingleInstance.Claim(paths, Impatient);
claim.ShouldNotBeNull();
File.Exists(paths.InstanceLockFile).ShouldBeTrue("the claim is a handle on a real file");
}
/// <remarks>
/// The point of the whole exercise. Nobody is listening in this test, so the second claim takes the
/// give-up path rather than the ask-and-leave one — which is why it is handed no patience at all.
/// </remarks>
[Fact]
public void ASecondLaunchIsRefused()
{
using var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
SingleInstance.Claim(paths, Impatient).ShouldBeNull();
}
/// <remarks>
/// ◆ There is no such thing as a stale lock here, and this is what says so. The handle is closed by the
/// runtime when it is disposed and by the kernel when the process ends however it ends — which is the
/// reason this is a file rather than a named mutex, whose abandonment is a state to be handled.
/// </remarks>
[Fact]
public void TheProfileIsClaimableAgainOnceTheFirstInstanceLetsGo()
{
var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
first.Dispose();
using var second = SingleInstance.Claim(paths, Impatient);
second.ShouldNotBeNull("the lock went with the handle");
}
/// <remarks>
/// The other half: a refused launch is not a silent one. Somebody who double-clicks the icon and sees
/// nothing has been told the application is broken, so the running instance is asked to come forward.
/// </remarks>
[Fact]
public async Task ARefusedLaunchAsksTheRunningInstanceToComeForward()
{
using var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
var asked = new TaskCompletionSource();
await using var doorbell = SingleInstance.Listen(paths, () => asked.TrySetResult());
SingleInstance.Claim(paths, Impatient).ShouldBeNull("the profile is taken");
// Awaited rather than polled: the callback comes off a thread pool thread. Bounded explicitly,
// because the test's own token is not — see LongEnoughForAPipe.
await asked.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
}
/// <remarks>
/// <para>
/// A third and a fourth launch work as well as the second, and this is the test that caught the doorbell
/// getting that wrong on Linux and only on Linux. A fresh <c>NamedPipeServerStream</c> per connection
/// closes the listening socket between callers there, so the second ring was accepted by nobody while
/// the launch that made it was told it had succeeded. See <c>SingleInstance.Doorbell</c>.
/// </para>
/// <para>
/// Two rings back to back rather than one and a pause, because the gap is the whole defect: a test that
/// waited for the first to be answered before making the second would pass against the broken version.
/// </para>
/// </remarks>
[Fact]
public async Task TheDoorbellAnswersMoreThanOnce()
{
using var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
var rings = 0;
var twice = new TaskCompletionSource();
await using var doorbell = SingleInstance.Listen(
paths,
() =>
{
if (Interlocked.Increment(ref rings) == 2)
{
twice.TrySetResult();
}
});
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
await twice.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
}
/// <remarks>
/// What the give-up path turns on. A lock held by something that is not listening — a process still
/// starting, or one on its way out — answers no, and that is what makes <c>Claim</c> wait and try the
/// lock again rather than concluding anything from the silence.
/// </remarks>
[Fact]
public void AskingWithNobodyListening_SaysSo()
{
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeFalse();
}
/// <remarks>
/// Two profiles, because a nightly and a release build are installed side by side on purpose and must
/// not exclude each other — ADR 0013. The lock file is inside the profile and the pipe name is derived
/// from it, so both halves are scoped by the one decision <c>ClientPaths.ForChannel</c> already made.
/// </remarks>
[Fact]
public void TwoProfilesDoNotExcludeEachOther()
{
var other = new ClientPaths(paths.DataDirectory + ".Nightly");
try
{
using var release = SingleInstance.Claim(paths, Impatient);
using var nightly = SingleInstance.Claim(other, Impatient);
release.ShouldNotBeNull();
nightly.ShouldNotBeNull("a second profile is a second application");
}
finally
{
if (Directory.Exists(other.DataDirectory))
{
Directory.Delete(other.DataDirectory, recursive: true);
}
}
}
// ---- A launch that carries a link ----
/// <remarks>
/// Clicking "Open in DodoSSH" in Spelunk while DodoSSH is running is a second launch like any other, and
/// the link has to reach the instance that can act on it — along with the window coming forward.
/// </remarks>
[Fact]
public async Task ARefusedLaunchWithALink_PassesTheLinkOn()
{
using var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
var cameForward = new TaskCompletionSource();
var opened = new TaskCompletionSource<SpelunkLink>();
await using var doorbell = SingleInstance.Listen(
paths,
() => cameForward.TrySetResult(),
link => opened.TrySetResult(link));
var link = Link();
SingleInstance.Claim(paths, Impatient, link).ShouldBeNull("the profile is taken");
await cameForward.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
(await opened.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken)).ShouldBe(link);
}
[Fact]
public void ThePlainRing_IsStillThePlainRing()
{
// An older running build compares the whole line against this, so a launch with no link must go on
// saying exactly it.
SingleInstance.Message(link: null).ShouldBe("dodossh.come-forward");
SingleInstance.TryRead("dodossh.come-forward", out var link).ShouldBeTrue();
link.ShouldBeNull();
}
[Fact]
public void ALinkMessage_ReadsBackToTheSameLink()
{
var link = Link();
SingleInstance.TryRead(SingleInstance.Message(link), out var read).ShouldBeTrue();
read.ShouldBe(link);
}
/// <remarks>
/// Anything on this machine can write to the pipe, so a message that is not exactly one of the two is
/// ignored whole — the window does not even come forward for it.
/// </remarks>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("dodossh.come-forward ")]
[InlineData("DODOSSH.COME-FORWARD")]
[InlineData("dodossh.open-link")]
[InlineData("dodossh.open-link https://evil.example")]
[InlineData("dodossh.open-link dodossh://spelunk/connect?api=http%3A%2F%2Fevil.example&server=0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b")]
[InlineData("dodossh.open-link dodossh://spelunk/connect?api=https%3A%2F%2Fapi.spelunk.example.com&server=not-a-server")]
[InlineData("dodossh.run calc.exe")]
public void AnythingElse_IsNotARing(string? said)
{
SingleInstance.TryRead(said, out var link).ShouldBeFalse();
link.ShouldBeNull();
}
[Fact]
public void AnOverlongMessage_IsNotARing()
{
SingleInstance.TryRead("dodossh.open-link " + new string('a', 64 * 1024), out _).ShouldBeFalse();
}
[Fact]
public async Task AGarbledRing_IsIgnored_AndTheDoorbellKeepsAnswering()
{
using var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
var rings = 0;
var answered = new TaskCompletionSource();
await using var doorbell = SingleInstance.Listen(
paths,
() =>
{
Interlocked.Increment(ref rings);
answered.TrySetResult();
});
// Somebody else's program, writing something that is not a ring and going on far past a line. The
// doorbell stops reading and hangs up part way, which the writer sees as a broken pipe.
using (var pipe = new NamedPipeClientStream(".", PipeNameOf(paths), PipeDirection.Out))
{
await pipe.ConnectAsync(TestContext.Current.CancellationToken);
try
{
await pipe.WriteAsync(
Encoding.UTF8.GetBytes(new string('x', 20_000) + "\n"),
TestContext.Current.CancellationToken);
}
catch (IOException)
{
// Hung up on, which is the point.
}
}
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
await answered.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
rings.ShouldBe(1, "the garbage rang nothing, and the real ring after it was still answered");
}
[Fact]
public void TheWindowsHandler_RunsThisExecutableWithTheLinkAsOneArgument()
{
Assert.SkipUnless(OperatingSystem.IsWindows(), "The registry handler exists only on Windows.");
const string executable = @"C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop\current\DodoSSH.exe";
const string nightly = @"C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop.Nightly\current\DodoSSH.exe";
WindowsUrlScheme.CommandFor(executable)
.ShouldBe(@"""C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop\current\DodoSSH.exe"" ""%1""");
// Uninstalling one channel must not take the handler away from the other if it registered since.
WindowsUrlScheme.IsOurs(WindowsUrlScheme.CommandFor(executable), executable).ShouldBeTrue();
WindowsUrlScheme.IsOurs(WindowsUrlScheme.CommandFor(nightly), executable).ShouldBeFalse();
WindowsUrlScheme.IsOurs(null, executable).ShouldBeFalse();
}
private static SpelunkLink Link() =>
new("https://api.spelunk.example.com", Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"), "web 01", "203.0.113.10");
/// <summary>The pipe name <c>SingleInstance</c> derives, so a test can write to it as a stranger would.</summary>
private static string PipeNameOf(ClientPaths profile)
{
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(profile.DataDirectory));
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
}
}