using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
namespace DodoSSH.Client.Ssh;
///
/// What one look at an endpoint found, once a connection to it had already failed.
///
///
///
/// ◆ Four, because they are the four a user would do something different about. 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 sshd
/// 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.
///
///
/// Nothing here is inferred from the exception that started it. 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 is written to.
///
///
public enum SshReach
{
/// The name does not resolve on this machine, so there was no address to dial.
Unresolved = 0,
/// It resolved, and the port sent nothing back before the probe gave up.
NoAnswer = 1,
/// It resolved, and the host said at once that nothing is listening there.
Refused = 2,
/// It resolved, and something accepted a plain TCP connection on that port.
Open = 3,
}
/// What a probe of one endpoint found, and the sentence that says so.
/// Which of the four it was.
/// The name or address that was probed, as the connection named it.
/// The port that was probed.
///
/// The address the name resolved to and the probe actually dialled, or null when it did not resolve.
///
/// 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.
///
///
/// How long the probe took, which is the evidence behind .
/// The finding in plain words, ready to put in front of somebody.
public sealed record SshReachabilityReport(
SshReach Reach,
string Host,
int Port,
string? Address,
TimeSpan Elapsed,
string Summary);
/// Looks at an endpoint that would not connect, and reports what is actually wrong with it.
///
/// Separate from 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.
///
public interface ISshReachabilityProbe
{
///
/// Resolves the name and opens a bare TCP connection to the port, to find out which part is broken.
///
/// The hostname or address the failed connection named.
/// The port it named.
/// Abandons the probe.
///
/// 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.
///
ValueTask ProbeAsync(string host, int port, CancellationToken cancellationToken);
}
///
///
///
/// ◆ Why this exists at all: two unrelated failures arrive as the same sentence. SSH.NET reports both
/// "the machine never answered" and "the machine answered TCP and then never spoke SSH" as
/// SshOperationTimeoutException 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 sshd 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.
///
///
/// A plain TCP connect and a name lookup, and deliberately nothing else. 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.
///
///
/// The whole probe is bounded, and the bound is short. 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.
///
///
public sealed class SshReachabilityProbe : ISshReachabilityProbe
{
///
/// How long the name lookup and the connection attempt get between them.
///
///
///
/// 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 SshNetConnectionFactory — 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.
///
///
/// ◆ Six rather than two, and the floor under it is not a guess. A refusal is not instant on
/// Windows: a closed port on loopback took ~2s to come back as WSAECONNREFUSED when this was
/// measured, because the stack retries the SYN before reporting. Anything at or under that turns
/// — a machine that is up — into , 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.
///
///
public static readonly TimeSpan Budget = TimeSpan.FromSeconds(6);
private readonly TimeSpan budget;
/// Uses the standard .
public SshReachabilityProbe()
: this(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.
///
public SshReachabilityProbe(TimeSpan budget) => this.budget = budget;
///
public async ValueTask 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);
}
///
/// The address the connection would have been made to, or null if the name does not resolve.
///
///
/// An address typed in rather than a name costs no lookup:
/// settles it here, where 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.
///
private static async ValueTask 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;
}
}
///
/// 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.
///
private static async ValueTask 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));
///
/// The finding as a sentence, saying what was measured and then what it means.
///
///
/// 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.
///
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.",
};
}
}