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
@@ -0,0 +1,281 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>
|
||||
/// What one look at an endpoint found, once a connection to it had already failed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Four, because they are the four a user would do something different about.</b> A name that does not
|
||||
/// resolve is a typo or a VPN that is down; a port that refuses is a machine that is up with no <c>sshd</c>
|
||||
/// on it; a port that says nothing at all is a firewall or a machine that is off; and a port that opens is
|
||||
/// the one that matters most, because it means the network is fine and the SSH handshake is what failed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here is inferred from the exception that started it.</b> Each value is what a socket actually
|
||||
/// did, measured at the moment it was asked, which is what makes it safe for a screen to state as fact —
|
||||
/// the same rule <see cref="SshConnectionPhase"/> is written to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum SshReach
|
||||
{
|
||||
/// <summary>The name does not resolve on this machine, so there was no address to dial.</summary>
|
||||
Unresolved = 0,
|
||||
|
||||
/// <summary>It resolved, and the port sent nothing back before the probe gave up.</summary>
|
||||
NoAnswer = 1,
|
||||
|
||||
/// <summary>It resolved, and the host said at once that nothing is listening there.</summary>
|
||||
Refused = 2,
|
||||
|
||||
/// <summary>It resolved, and something accepted a plain TCP connection on that port.</summary>
|
||||
Open = 3,
|
||||
}
|
||||
|
||||
/// <summary>What a probe of one endpoint found, and the sentence that says so.</summary>
|
||||
/// <param name="Reach">Which of the four it was.</param>
|
||||
/// <param name="Host">The name or address that was probed, as the connection named it.</param>
|
||||
/// <param name="Port">The port that was probed.</param>
|
||||
/// <param name="Address">
|
||||
/// The address the name resolved to and the probe actually dialled, or null when it did not resolve.
|
||||
/// <para>
|
||||
/// One rather than all of them. A name with several addresses is dialled at whichever the stack picks
|
||||
/// first, and naming the one that was tried is a fact; listing the rest would suggest they were.
|
||||
/// </para>
|
||||
/// </param>
|
||||
/// <param name="Elapsed">How long the probe took, which is the evidence behind <see cref="SshReach.NoAnswer"/>.</param>
|
||||
/// <param name="Summary">The finding in plain words, ready to put in front of somebody.</param>
|
||||
public sealed record SshReachabilityReport(
|
||||
SshReach Reach,
|
||||
string Host,
|
||||
int Port,
|
||||
string? Address,
|
||||
TimeSpan Elapsed,
|
||||
string Summary);
|
||||
|
||||
/// <summary>Looks at an endpoint that would not connect, and reports what is actually wrong with it.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="ISshConnectionFactory"/> rather than a method on it, because it is not a way of
|
||||
/// connecting: it runs after one has failed, it authenticates nothing, and it must stay usable from a caller
|
||||
/// that has no credential to hand — which the failure path is, by the time it needs this.
|
||||
/// </remarks>
|
||||
public interface ISshReachabilityProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the name and opens a bare TCP connection to the port, to find out which part is broken.
|
||||
/// </summary>
|
||||
/// <param name="host">The hostname or address the failed connection named.</param>
|
||||
/// <param name="port">The port it named.</param>
|
||||
/// <param name="cancellationToken">Abandons the probe.</param>
|
||||
/// <remarks>
|
||||
/// Never throws for a host that is simply broken — being unreachable is the answer, not an error. A
|
||||
/// caller is already in a failure path and has nothing useful to do with a second exception.
|
||||
/// </remarks>
|
||||
ValueTask<SshReachabilityReport> ProbeAsync(string host, int port, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Why this exists at all: two unrelated failures arrive as the same sentence.</b> SSH.NET reports both
|
||||
/// "the machine never answered" and "the machine answered TCP and then never spoke SSH" as
|
||||
/// <c>SshOperationTimeoutException</c> with the message "Connection has timed out." Those are not the same
|
||||
/// problem and do not have the same fix — one is a network or a firewall, the other is an <c>sshd</c> that
|
||||
/// is wedged or a port that is not SSH at all — and nothing in the exception tells them apart. A socket does,
|
||||
/// in about as long as it takes to open one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A plain TCP connect and a name lookup, and deliberately nothing else.</b> No ping, because ICMP is
|
||||
/// filtered on most of the networks this product is pointed at and an unanswered ping 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 the kind of thing that gets a client flagged by an intrusion
|
||||
/// detector on the very network somebody is trying to work on. No SSH banner read either — that is a second
|
||||
/// handshake against a server that has already failed one, and the open socket has answered the question.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The whole probe is bounded, and the bound is short.</b> By the time this runs the user has already
|
||||
/// waited out a connect timeout, so the budget here is a few seconds spent buying an answer rather than
|
||||
/// another wait to sit through. Exceeding it is itself a finding — nothing came back — so the deadline
|
||||
/// produces a result rather than an error.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SshReachabilityProbe : ISshReachabilityProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// How long the name lookup and the connection attempt get between them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A budget rather than two timeouts, so a slow resolver cannot spend the socket's share and leave the
|
||||
/// interesting half of the probe unrun. It is well under the fifteen this client allows a real connection
|
||||
/// — see <c>SshNetConnectionFactory</c> — because a machine that answers at all answers a bare TCP
|
||||
/// connect in a fraction of what an SSH handshake takes, and everything past that point is waiting to
|
||||
/// confirm silence somebody has already waited out once.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Six rather than two, and the floor under it is not a guess.</b> A refusal is not instant on
|
||||
/// Windows: a closed port on loopback took ~2s to come back as <c>WSAECONNREFUSED</c> when this was
|
||||
/// measured, because the stack retries the SYN before reporting. Anything at or under that turns
|
||||
/// <see cref="SshReach.Refused"/> — a machine that is up — into <see cref="SshReach.NoAnswer"/>, which
|
||||
/// this client would then explain as a firewall. The budget must clear the platform's SYN-retry time
|
||||
/// with room to spare, and a couple of seconds of headroom is what the extra four buy. Shortening this
|
||||
/// without re-measuring that floor reintroduces a wrong answer rather than a slow one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static readonly TimeSpan Budget = TimeSpan.FromSeconds(6);
|
||||
|
||||
private readonly TimeSpan budget;
|
||||
|
||||
/// <summary>Uses the standard <see cref="Budget"/>.</summary>
|
||||
public SshReachabilityProbe()
|
||||
: this(Budget)
|
||||
{
|
||||
}
|
||||
|
||||
/// <param name="budget">
|
||||
/// How long the whole probe gets. A test uses this to keep a deliberately silent endpoint from costing
|
||||
/// the suite six seconds; nothing in the application passes one.
|
||||
/// </param>
|
||||
public SshReachabilityProbe(TimeSpan budget) => this.budget = budget;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<SshReachabilityReport> ProbeAsync(
|
||||
string host,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(host);
|
||||
|
||||
var clock = Stopwatch.StartNew();
|
||||
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(budget);
|
||||
|
||||
var address = await ResolveAsync(host, deadline.Token).ConfigureAwait(false);
|
||||
|
||||
if (address is null)
|
||||
{
|
||||
return Report(SshReach.Unresolved, host, port, address: null, clock.Elapsed);
|
||||
}
|
||||
|
||||
var reach = await ConnectAsync(address, port, deadline.Token).ConfigureAwait(false);
|
||||
|
||||
return Report(reach, host, port, address.ToString(), clock.Elapsed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The address the connection would have been made to, or null if the name does not resolve.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An address typed in rather than a name costs no lookup: <see cref="IPAddress.TryParse(string, out IPAddress)"/>
|
||||
/// settles it here, where <see cref="Dns"/> would go and ask the resolver about a string it is going to
|
||||
/// hand straight back. That matters for the report as much as for the speed — a literal that "resolved"
|
||||
/// would be a fact this probe invented about a machine it never asked anybody about.
|
||||
/// </remarks>
|
||||
private static async ValueTask<IPAddress?> ResolveAsync(string host, CancellationToken cancellationToken)
|
||||
{
|
||||
if (IPAddress.TryParse(host, out var literal))
|
||||
{
|
||||
return literal;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// First rather than a preference between v4 and v6: this is reporting on the connection that
|
||||
// failed, and the stack picked from this same list in this same order when it made it.
|
||||
return addresses.Length == 0 ? null : addresses[0];
|
||||
}
|
||||
catch (Exception exception) when (exception is SocketException or OperationCanceledException)
|
||||
{
|
||||
// A resolver that refuses and a resolver that never answers are the same finding here: there is
|
||||
// no address, so there was nothing to dial. Which of the two it was is a DNS question, and this
|
||||
// probe would be guessing at the answer.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The socket is opened and dropped without a byte being sent. A TCP handshake is the whole question —
|
||||
/// whether anything is listening — and writing to a server that is about to be told nothing is wanted
|
||||
/// would leave a connection attempt in its log that the user did not make.
|
||||
/// </remarks>
|
||||
private static async ValueTask<SshReach> ConnectAsync(
|
||||
IPAddress address,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return SshReach.Open;
|
||||
}
|
||||
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionRefused)
|
||||
{
|
||||
return SshReach.Refused;
|
||||
}
|
||||
catch (Exception exception) when (exception is SocketException or OperationCanceledException)
|
||||
{
|
||||
// Everything else the stack can say here — unreachable, timed out, the deadline above — comes to
|
||||
// the same account of it: the port never answered. Naming the OS error instead would be trading a
|
||||
// sentence somebody can act on for one they would have to look up.
|
||||
return SshReach.NoAnswer;
|
||||
}
|
||||
}
|
||||
|
||||
private static SshReachabilityReport Report(
|
||||
SshReach reach,
|
||||
string host,
|
||||
int port,
|
||||
string? address,
|
||||
TimeSpan elapsed)
|
||||
=> new(reach, host, port, address, elapsed, Describe(reach, host, port, address, elapsed));
|
||||
|
||||
/// <summary>
|
||||
/// The finding as a sentence, saying what was measured and then what it means.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Measurement first in every case, on the rule the rest of this product's failure text follows: the
|
||||
/// fact is what the user can check and act on, and the interpretation after it is this client's reading
|
||||
/// of that fact. Each reading is hedged exactly as far as it deserves — a refusal has one common cause
|
||||
/// and names it, silence has three and lists them, and an open port names the class of problem rather
|
||||
/// than pretending to know which member of it this is.
|
||||
/// </remarks>
|
||||
private static string Describe(SshReach reach, string host, int port, string? address, TimeSpan elapsed)
|
||||
{
|
||||
var seconds = elapsed.TotalSeconds.ToString("0.#", CultureInfo.CurrentCulture);
|
||||
var where = address is null || string.Equals(address, host, StringComparison.OrdinalIgnoreCase)
|
||||
? host
|
||||
: $"{host} ({address})";
|
||||
|
||||
return reach switch
|
||||
{
|
||||
SshReach.Unresolved =>
|
||||
$"The name {host} does not resolve on this machine, so nothing was ever dialled. Check the "
|
||||
+ "spelling, or whether the VPN or DNS server this name depends on is up.",
|
||||
|
||||
SshReach.Refused =>
|
||||
$"{where} is reachable and refused port {port} immediately: the machine is up and nothing is "
|
||||
+ "listening there. Either sshd is not running, or this is the wrong port.",
|
||||
|
||||
SshReach.NoAnswer =>
|
||||
$"{where} sent nothing back on port {port} within {seconds}s. A machine that was up and had "
|
||||
+ "nothing listening would have refused at once, so this is a firewall dropping the "
|
||||
+ "packets, a machine that is off, or the wrong address.",
|
||||
|
||||
_ =>
|
||||
$"Port {port} on {where} accepts connections, so the network and the machine are fine and "
|
||||
+ "something is listening. The SSH handshake is what did not finish: an sshd that is "
|
||||
+ "overloaded or wedged, a middlebox holding the connection open, or a service on this "
|
||||
+ "port that is not SSH.",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user