Public Access
Find out why a connection did not reach, instead of only saying it timed out
Measured against SSH.NET 2026.0.0 before anything was written, because the
premise needed checking. Four ways of failing to reach a host, and what this
client puts in front of the user for each:
server accepts TCP, never sends a banner -> "Connection has timed out."
packets black-holed, nothing comes back -> "Connection has timed out."
nothing listening on the port -> "...actively refused it."
name does not resolve -> "No such host is known."
The first two are the same five words and are not the same problem. One is a
network, a firewall or a wrong address; the other is a machine that is up and
well and an sshd that is wedged, or a port that is not SSH at all. Nothing in
the exception tells them apart, and that sentence was the whole of what this
product could say about the failure people hit most.
◆ A SOCKET ANSWERS IT, IN ABOUT AS LONG AS IT TAKES TO OPEN ONE.
SshReachabilityProbe resolves the name and opens a bare TCP connection, and the
four answers it can come back with are the four somebody would do something
different about — unresolved, refused, silent, open. Every one of them is what
a socket actually did, measured at the moment it was asked, rather than
anything inferred from the exception that started it.
Deliberately nothing else. No ping: ICMP is filtered on most of the networks
this is pointed at, so an unanswered one would be evidence of nothing. No scan
of neighbouring ports — the question is what happened to the connection the
user asked for, and probing ports nobody asked about is how a client gets
flagged by an intrusion detector on the very network somebody is trying to
work on. No second SSH handshake against a server that has already failed one.
The budget is six seconds and the floor under it is not a guess: a refusal is
not immediate on Windows, and a closed loopback port took ~2s to answer when
this was measured, because the stack retries the SYN first. At two seconds the
probe calls a machine that is up and refusing a firewall. The test for that
case runs at the real budget on purpose, so lowering the number fails it.
◆ ONLY WHILE REACHING, AND THE WINDOW IS CLOSED AT BOTH ENDS.
Past SshConnectionPhase.Reaching the machine has offered a host key, so it has
demonstrably been reached and a refused password is not a reachability
question. Before it, nothing has been dialled at all — a private key that will
not parse throws out of BuildConnectionInfo before the factory reports
anything — and probing there finds a healthy port and appends "the SSH
handshake is what did not finish" to a message about an unreadable key. That
is a confident wrong answer replacing an accurate one, so both ends are shut.
PhaseReporter keeps the furthest phase to decide it.
The finding is added to what SSH.NET said rather than replacing it: the
exception is the fact, and this is a reading of a second measurement.
Every shell in ShellFlowTests now gets a scripted probe. Without one a failed
connection in any test in that file would do a live DNS lookup for db.internal
and open a real socket — slow, network-dependent, and worse than either, since
db.internal resolves on any machine whose resolver has a search domain that
makes it, which would make the asserted sentence depend on whose network the
suite ran from.
◆ WHAT THIS DOES NOT FIX, FOUND WHILE READING THE SAME PATH.
- SshConnectionRequest.ConnectTimeout is documented, plumbed, and passed by
nothing. Every connection this client makes is hard-wired to the 15-second
default, and there is no per-host field and no setting that can change it.
Fifteen seconds bounds the handshake waits as well as the connect — a
server that accepts TCP and never sends a banner fails at exactly the
timeout, measured — where OpenSSH's own ConnectTimeout covers only the
connect. If timeouts are frequent on slow links, this is the first
suspect, and it needs a product decision rather than a patch.
- The known-host lookup in HostKeyGate.OnHostKeyReceived blocks SSH.NET's
message thread inside that same 15-second budget. The blocking is
acknowledged in the code; that it competes with the key exchange's own
timeout is not. On a cold or locked vault a slow read would surface as a
connection timeout, which is exactly the symptom above.
- ConnectThroughHostKeyGateAsync never unsubscribes HostKeyReceived on
success, so the gate outlives the handshake holding the connect-time
CancellationToken. If SSH.NET raises that event again on rekey it would
re-enter the blocking store read with a token whose source is long gone.
Not verified either way — it needs a real sshd and a forced rekey.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dabc691c35
commit
fd92b80d4f
6 files changed
+774
-9
No files matched your search
@@ -1445,8 +1445,18 @@ internal sealed partial class VaultViewModel(
|
||||
ServerReconnectHandler? reconnect = null,
|
||||
Func<string, Task>? copyToClipboard = null,
|
||||
ConnectionRecorder? connectionLog = null,
|
||||
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
|
||||
VaultVisibility? visibility = null,
|
||||
ISshReachabilityProbe? reachability = null) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
|
||||
/// <remarks>
|
||||
/// Static and shared, because <see cref="SshReachabilityProbe"/> holds nothing but a budget and every
|
||||
/// call to it is a fresh socket. Last in the parameter list and optional for the reason
|
||||
/// <c>visibility</c> is: every existing caller — which is both heads and every test — goes on meaning
|
||||
/// exactly what it did, and the one that wants to watch what was probed passes a substitute.
|
||||
/// </remarks>
|
||||
private static readonly ISshReachabilityProbe DefaultReachability = new SshReachabilityProbe();
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
||||
@@ -11233,7 +11243,52 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private sealed class PhaseReporter(Action<SshConnectionPhase> report) : IProgress<SshConnectionPhase>
|
||||
{
|
||||
public void Report(SshConnectionPhase value) => report(value);
|
||||
private int furthest = -1;
|
||||
|
||||
/// <summary>
|
||||
/// The furthest phase reported, or null if the handshake never reported one at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Kept because a failure cannot be read without it: <see cref="ExplainAsync"/> probes the endpoint
|
||||
/// only for a connection that stopped while reaching, and this is the record of how far it got.
|
||||
/// Nothing else asks — the card is driven by the events, not by this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>An <see cref="int"/> behind a <see cref="Volatile"/> pair, because the write and the read are
|
||||
/// on different threads.</b> Phases are reported from whichever thread the handshake is on — SSH.NET
|
||||
/// raises host key verification on its own message thread, which is where
|
||||
/// <see cref="SshConnectionPhase.CheckingHostKey"/> and
|
||||
/// <see cref="SshConnectionPhase.Authenticating"/> come from — and this is read on the UI thread
|
||||
/// after the attempt has failed. The <c>await</c> in between orders them in practice; nothing in the
|
||||
/// language says so, and a cached read here would probe a host that had already offered a key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Monotonic, so a phase cannot go backwards.</b> The enum only ever advances today, and the
|
||||
/// maximum rather than the latest is what keeps that an observation rather than something this type
|
||||
/// relies on: a rekey mid-handshake raising <c>HostKeyReceived</c> a second time must not be able to
|
||||
/// walk the record back to <see cref="SshConnectionPhase.CheckingHostKey"/> from
|
||||
/// <see cref="SshConnectionPhase.OpeningShell"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public SshConnectionPhase? Furthest =>
|
||||
Volatile.Read(ref furthest) is var seen && seen < 0 ? null : (SshConnectionPhase)seen;
|
||||
|
||||
public void Report(SshConnectionPhase value)
|
||||
{
|
||||
var reported = (int)value;
|
||||
|
||||
// Read-compare-write rather than an interlocked maximum, which the BCL has no primitive for. The
|
||||
// race it loses is two phases reported at the same instant from two threads, which this
|
||||
// handshake does not do — it is one connection making one call at a time — and the cost of
|
||||
// losing it would be one phase forgotten rather than a wrong answer.
|
||||
if (reported > Volatile.Read(ref furthest))
|
||||
{
|
||||
Volatile.Write(ref furthest, reported);
|
||||
}
|
||||
|
||||
report(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
|
||||
@@ -11506,9 +11561,13 @@ internal sealed partial class VaultViewModel(
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Built here rather than inside the call, because the catch blocks below read it: how far the
|
||||
// handshake got is what says whether a failure is worth probing the endpoint over. See ExplainAsync.
|
||||
var reporter = ReporterFor(attempt);
|
||||
|
||||
try
|
||||
{
|
||||
await ConnectAndAnnounceAsync(attempt, target, authentication, cancellationToken)
|
||||
await ConnectAndAnnounceAsync(attempt, target, authentication, reporter, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
@@ -11534,10 +11593,87 @@ internal sealed partial class VaultViewModel(
|
||||
catch (Exception exception)
|
||||
{
|
||||
RecordFailure(target, authentication, ConnectionOutcome.Failed);
|
||||
Abandon(attempt, exception.Message);
|
||||
|
||||
Abandon(
|
||||
attempt,
|
||||
await ExplainAsync(exception, target, reporter, cancellationToken).ConfigureAwait(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What to tell the user about a connection that failed, having gone and looked when the message alone
|
||||
/// does not say.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Because "Connection has timed out." is what SSH.NET says for two unrelated faults.</b> A machine
|
||||
/// that never answered and a machine that answered TCP and then never spoke SSH arrive here as the same
|
||||
/// five words, and they have nothing in common to do about them — the first is a network, a firewall or a
|
||||
/// wrong address, the second is an <c>sshd</c> that is wedged or a port that is not SSH. That was the
|
||||
/// whole of what this screen could say about the failure users hit most, so it now goes and finds out;
|
||||
/// see <see cref="ISshReachabilityProbe"/> for what it does and deliberately does not do.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Only for a failure that happened while reaching, and the phase is what settles that.</b> The window
|
||||
/// is exactly <see cref="SshConnectionPhase.Reaching"/>, and it is closed at both ends. Past it the
|
||||
/// machine has demonstrably been reached — it offered a host key — so a refused password or a key the
|
||||
/// remote would not take is not a reachability question, and probing the port would answer one nobody
|
||||
/// asked while telling somebody their network is fine when they already know.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>And before it, nothing has been dialled yet, which is the half that would lie.</b> No phase at
|
||||
/// all means the attempt never reached <c>ConnectAsync</c>: a renderer that did not attach, a credential
|
||||
/// that would not decrypt, a private key that would not parse — <c>BuildConnectionInfo</c> throws on that
|
||||
/// last one before the factory reports anything. Probing there finds a healthy port and appends "the SSH
|
||||
/// handshake is what did not finish" to a message about an unreadable key, which is a confident wrong
|
||||
/// answer where there was an accurate one. The phases are reported at the instant each begins and are not
|
||||
/// guesses; see the enum.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The original message is kept and the finding is added to it.</b> What SSH.NET said is the fact, and
|
||||
/// this is a reading of a second measurement — dropping the first would leave nothing to check the second
|
||||
/// against, and would lose the one case where the exception is already specific.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A probe that itself fails changes nothing.</b> The user is being told about a connection that did
|
||||
/// not happen; a diagnostic that cannot run is not a second failure worth reporting, and must not be able
|
||||
/// to replace the first with its own exception. Cancellation is in that: a user who closed the tab while
|
||||
/// this ran is not waiting for the answer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<string> ExplainAsync(
|
||||
Exception exception,
|
||||
ConnectionTarget target,
|
||||
PhaseReporter reporter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (reporter.Furthest != SshConnectionPhase.Reaching)
|
||||
{
|
||||
return exception.Message;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var report = await Reachability
|
||||
.ProbeAsync(target.Hostname, target.Port, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// A probe with nothing to say leaves the message exactly as it was, rather than appending a
|
||||
// space to it. The real one always has a sentence; this is what keeps a substitute that does not
|
||||
// from changing the text of every failure that goes past it.
|
||||
return report.Summary is { Length: > 0 } finding
|
||||
? $"{exception.Message} {finding}"
|
||||
: exception.Message;
|
||||
}
|
||||
catch (Exception probe) when (probe is not OutOfMemoryException)
|
||||
{
|
||||
return exception.Message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="DefaultReachability" />
|
||||
private ISshReachabilityProbe Reachability => reachability ?? DefaultReachability;
|
||||
|
||||
/// <summary>What a renderer that never attached is reported as.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -11603,6 +11739,7 @@ internal sealed partial class VaultViewModel(
|
||||
ConnectionAttemptEventArgs attempt,
|
||||
ConnectionTarget target,
|
||||
HostAuthentication authentication,
|
||||
PhaseReporter reporter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Not reported before the await: the tab is constructed with this step already running — see
|
||||
@@ -11616,7 +11753,7 @@ internal sealed partial class VaultViewModel(
|
||||
authentication.Credential);
|
||||
|
||||
var sessionId = await workspace
|
||||
.OpenSessionAsync(request, TerminalSize.Default, ReporterFor(attempt), cancellationToken)
|
||||
.OpenSessionAsync(request, TerminalSize.Default, reporter, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||
|
||||
Reference in new issue
Block a user