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.", }; } }