diff --git a/docs/528-error-code.md b/docs/528-error-code.md new file mode 100644 index 000000000..f8e67886e --- /dev/null +++ b/docs/528-error-code.md @@ -0,0 +1,102 @@ +# 528 transport error and `x-fluxzy-network-error` + +When Fluxzy fails to relay a request because of an upstream network, DNS, or TLS +problem it returns a synthesized `528 Fluxzy transport error` response (or `502 +Bad Gateway` when `FluxzySharedSetting.Use528 = false`). + +To let programmatic consumers react without parsing the body, every synthesized +528 carries an extra response header: + +``` +x-fluxzy-network-error: +``` + +The same token is persisted on `ClientError.NetworkErrorCode` in archives +(`.fxzy` / HAR) so post-mortem readers can recover it. + +The header value is one of the stable identifiers below, defined as +`public const string` on `Fluxzy.Core.NetworkErrorCodes`. The strings are +considered a public contract and will not change without a major version bump. + +## Token reference + +### Connection layer + +| Token | Description | +| ---------------------- | ------------------------------------------------------------------------------------------------- | +| `connection_refused` | The remote peer responded but actively refused the TCP connection (RST on SYN). | +| `connection_reset` | The remote peer reset an established connection. | +| `connection_aborted` | The connection was aborted, often because the remote half closed without a clean FIN. | +| `connection_timeout` | The remote peer could not be contacted within the configured TCP connect timeout. | +| `host_unreachable` | The OS reports the remote host as unreachable (no route, ICMP host-unreachable). | +| `network_unreachable` | The OS reports the remote network as unreachable (no route at the network level). | +| `connection_closed` | The remote peer closed the TCP connection while Fluxzy was reading the response header. | + +### DNS layer + +| Token | Description | +| ---------------- | ---------------------------------------------------------------------------------------------------------- | +| `dns_notfound` | The DNS server returned NXDOMAIN: the requested host does not exist. | +| `dns_no_data` | The DNS server resolved the name but returned no usable A/AAAA record. | +| `dns_try_again` | The DNS server returned a transient failure (SERVFAIL or equivalent); a retry might succeed. | +| `dns_failure` | Generic DNS resolution failure: malformed response, unreachable resolver, DoH endpoint returning non-2xx. | + +### TLS layer + +| Token | Description | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| `tls_cert_expired` | The server certificate is past its validity period (or not yet valid). | +| `tls_cert_hostname_mismatch` | The server certificate is valid but does not cover the requested hostname (Subject / SAN mismatch). | +| `tls_cert_untrusted` | The server certificate chain does not chain to a trusted root (untrusted root, partial chain, no policy). | +| `tls_cert_invalid` | Other certificate-policy failure: revoked, malformed, unknown to the validator, or required but missing. | +| `tls_handshake_failure` | TLS handshake failed for a reason that is not specific to the certificate (alert, version, cipher, ...). | + +### Other + +| Token | Description | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `protocol_error` | An HTTP/2 stream protocol error happened before the response header was received. | +| `rule_failure` | A user-supplied Fluxzy rule (filter or action) threw during exchange processing. See the body for the rule diagnostic. | +| `unknown` | Fallback when none of the categories above match. Typically a wrapped exception that did not carry enough information. | + +## Example + +```http +HTTP/1.1 528 Fluxzy error +x-fluxzy: Fluxzy error +x-fluxzy-network-error: tls_cert_expired +Content-length: 7520 +Content-type: text/html; charset=utf-8 +Connection: close + +... +``` + +## Reading the token from C# + +```csharp +using var response = await httpClient.GetAsync(url); + +if ((int)response.StatusCode == 528 && + response.Headers.TryGetValues("x-fluxzy-network-error", out var values)) +{ + var token = values.Single(); + // e.g. switch on Fluxzy.Core.NetworkErrorCodes.* constants +} +``` + +## Reading the token from an archive + +```csharp +foreach (var clientError in exchange.ClientErrors) +{ + var token = clientError.NetworkErrorCode; // null on success +} +``` + +## See also + +- `Fluxzy.Core.NetworkErrorCodes` (source: `src/Fluxzy.Core/Core/NetworkErrorCodes.cs`) +- `ClientError.NetworkErrorCode` (source: `src/Fluxzy.Core/ClientError.cs`) +- `NetworkErrorFilter` selects exchanges with `StatusCode == 528` + (source: `src/Fluxzy.Core/Rules/Filters/ResponseFilters/NetworkErrorFilter.cs`) diff --git a/src/Fluxzy.Core/ClientError.cs b/src/Fluxzy.Core/ClientError.cs index d0ea4087b..1c7e6e5de 100644 --- a/src/Fluxzy.Core/ClientError.cs +++ b/src/Fluxzy.Core/ClientError.cs @@ -1,68 +1,87 @@ -// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak - -using System; -using MessagePack; - -namespace Fluxzy -{ - /// - /// Holds information about a client error - /// - [MessagePackObject] - public class ClientError - { - /// - /// Create a new instance from error code and message - /// - /// - /// - public ClientError(int errorCode, string message) - { - ErrorCode = errorCode; - Message = message; - } - - /// - /// OS error code - /// - [Key(0)] - public int ErrorCode { get; } - - /// - /// Friendly error message - /// - [Key(1)] - public string Message { get; } - - /// - /// Exception message - /// - [Key(2)] - public string? ExceptionMessage { get; set; } - - - protected bool Equals(ClientError other) - { - return ErrorCode == other.ErrorCode && Message == other.Message && ExceptionMessage == other.ExceptionMessage; - } - - public override bool Equals(object? obj) - { - if (ReferenceEquals(null, obj)) - return false; - - if (ReferenceEquals(this, obj)) - return true; - - if (obj.GetType() != this.GetType()) - return false; - - return Equals((ClientError)obj); - } - - public override int GetHashCode() - { - return HashCode.Combine(ErrorCode, Message, ExceptionMessage); - } - } -} +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System; +using MessagePack; + +namespace Fluxzy +{ + /// + /// Holds information about a client error + /// + [MessagePackObject] + public class ClientError + { + /// + /// Create a new instance from error code and message + /// + /// + /// + public ClientError(int errorCode, string message) + { + ErrorCode = errorCode; + Message = message; + } + + /// + /// Create a new instance with an explicit network error code + /// + public ClientError(int errorCode, string message, string? networkErrorCode) + { + ErrorCode = errorCode; + Message = message; + NetworkErrorCode = networkErrorCode; + } + + /// + /// OS error code + /// + [Key(0)] + public int ErrorCode { get; } + + /// + /// Friendly error message + /// + [Key(1)] + public string Message { get; } + + /// + /// Exception message + /// + [Key(2)] + public string? ExceptionMessage { get; set; } + + /// + /// Stable errno-like identifier for the kind of network failure (e.g. connection_refused, + /// dns_notfound, tls_cert_expired). See . + /// + [Key(3)] + public string? NetworkErrorCode { get; set; } + + + protected bool Equals(ClientError other) + { + return ErrorCode == other.ErrorCode && Message == other.Message + && ExceptionMessage == other.ExceptionMessage + && NetworkErrorCode == other.NetworkErrorCode; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + return false; + + if (ReferenceEquals(this, obj)) + return true; + + if (obj.GetType() != this.GetType()) + return false; + + return Equals((ClientError)obj); + } + + public override int GetHashCode() + { + return HashCode.Combine(ErrorCode, Message, ExceptionMessage, NetworkErrorCode); + } + } +} diff --git a/src/Fluxzy.Core/Clients/Dns/DefaultDnsResolver.cs b/src/Fluxzy.Core/Clients/Dns/DefaultDnsResolver.cs index 23e2a8f6a..da69679c5 100644 --- a/src/Fluxzy.Core/Clients/Dns/DefaultDnsResolver.cs +++ b/src/Fluxzy.Core/Clients/Dns/DefaultDnsResolver.cs @@ -31,16 +31,21 @@ public async Task> SolveDnsAll(string hostName) catch (Exception ex) { var errorCode = -1; + var networkErrorCode = NetworkErrorCodes.DnsFailure; - if (ex is SocketException sex) + if (ex is SocketException sex) { errorCode = sex.ErrorCode; + networkErrorCode = MapDnsSocketError(sex.SocketErrorCode); + } var clientErrorException = new ClientErrorException( - errorCode, $"Failed to solve DNS for {hostName}", ex.Message); + errorCode, $"Failed to solve DNS for {hostName}", + innerMessageException: ex.Message, + networkErrorCode: networkErrorCode); throw clientErrorException; } - + } public async Task SolveDns(string hostName) @@ -53,7 +58,8 @@ public async Task SolveDns(string hostName) if (found == null) throw new ClientErrorException(-1, $"Failed to solve DNS for {hostName}", - "No IP address found"); + innerMessageException: "No IP address found", + networkErrorCode: NetworkErrorCodes.DnsNoData); return found; } @@ -71,9 +77,19 @@ protected virtual async Task> InternalSolveDns(string hos return await SolveDns(hostName).ConfigureAwait(false); } catch { - // it's quiet solving + // it's quiet solving return null; } } + + internal static string MapDnsSocketError(SocketError socketError) + { + return socketError switch { + SocketError.HostNotFound => NetworkErrorCodes.DnsNotFound, + SocketError.NoData => NetworkErrorCodes.DnsNoData, + SocketError.TryAgain => NetworkErrorCodes.DnsTryAgain, + _ => NetworkErrorCodes.DnsFailure + }; + } } } diff --git a/src/Fluxzy.Core/Clients/Dns/DnsOverHttpsResolver.cs b/src/Fluxzy.Core/Clients/Dns/DnsOverHttpsResolver.cs index 074769257..a2ea87255 100644 --- a/src/Fluxzy.Core/Clients/Dns/DnsOverHttpsResolver.cs +++ b/src/Fluxzy.Core/Clients/Dns/DnsOverHttpsResolver.cs @@ -129,13 +129,20 @@ public DnsOverHttpsResolver(string nameOrUrl, ProxyConfiguration? proxyConfigura if (dnsResponse == null) { - throw new ClientErrorException(0, "Invalid DNS response (null)"); + throw new ClientErrorException(0, "Invalid DNS response (null)", + networkErrorCode: NetworkErrorCodes.DnsFailure); } if (dnsResponse.Status != 0) { + // RFC 8484 / DNS RCODE 3 = NXDOMAIN + var token = dnsResponse.Status == 3 + ? NetworkErrorCodes.DnsNotFound + : NetworkErrorCodes.DnsFailure; + throw new ClientErrorException(0, - $"Failed to resolve DNS over HTTPS. Status response = {dnsResponse.Status}"); + $"Failed to resolve DNS over HTTPS. Status response = {dnsResponse.Status}", + networkErrorCode: token); } var result = dnsResponse.Answers diff --git a/src/Fluxzy.Core/Clients/H11/Http11PoolProcessing.cs b/src/Fluxzy.Core/Clients/H11/Http11PoolProcessing.cs index 9d944a8c4..9018eac3f 100644 --- a/src/Fluxzy.Core/Clients/H11/Http11PoolProcessing.cs +++ b/src/Fluxzy.Core/Clients/H11/Http11PoolProcessing.cs @@ -195,7 +195,9 @@ await ForwardInterimToClient(exchange, earlyStatus, cancellationToken) throw new ClientErrorException(0, "The connection was closed while trying to read the response header", - ex.Message, ex); + innerMessageException: ex.Message, + innerException: ex, + networkErrorCode: NetworkErrorCodes.ConnectionClosed); } } diff --git a/src/Fluxzy.Core/Clients/H2/StreamWorker.cs b/src/Fluxzy.Core/Clients/H2/StreamWorker.cs index 50b9eced0..a780fd54b 100644 --- a/src/Fluxzy.Core/Clients/H2/StreamWorker.cs +++ b/src/Fluxzy.Core/Clients/H2/StreamWorker.cs @@ -465,7 +465,8 @@ public async ValueTask ProcessResponse(CancellationToken cancellationToken, H2Co } throw new ClientErrorException(1, - "The connection was interrupted before receiving response header"); + "The connection was interrupted before receiving response header", + networkErrorCode: NetworkErrorCodes.ProtocolError); } catch (Exception) { Parent.NotifyDispose(this); diff --git a/src/Fluxzy.Core/Clients/Ssl/BouncyCastle/BouncyCastleConnectionBuilder.cs b/src/Fluxzy.Core/Clients/Ssl/BouncyCastle/BouncyCastleConnectionBuilder.cs index 4bf3914a6..0192c56d5 100644 --- a/src/Fluxzy.Core/Clients/Ssl/BouncyCastle/BouncyCastleConnectionBuilder.cs +++ b/src/Fluxzy.Core/Clients/Ssl/BouncyCastle/BouncyCastleConnectionBuilder.cs @@ -65,7 +65,13 @@ public async Task AuthenticateAsClient( } catch (Exception ex) { - throw new ClientErrorException(0, $"Handshake with {builderOptions.TargetHost} has failed", ex.Message); + var networkErrorCode = MapTlsAlert(ex); + + throw new ClientErrorException(0, + $"Handshake with {builderOptions.TargetHost} has failed", + innerMessageException: ex.Message, + innerException: ex, + networkErrorCode: networkErrorCode); } var keyInfos = @@ -90,5 +96,32 @@ public async Task AuthenticateAsClient( return connection; } + + internal static string MapTlsAlert(Exception ex) + { + // Walk the inner-exception chain looking for a TlsFatalAlert raised by + // either the BC stack itself or our FluxzyTlsAuthentication validator. + for (var current = ex; current != null; current = current.InnerException) { + if (current is Org.BouncyCastle.Tls.TlsFatalAlert alert) { + return alert.AlertDescription switch { + Org.BouncyCastle.Tls.AlertDescription.certificate_expired + => NetworkErrorCodes.TlsCertExpired, + Org.BouncyCastle.Tls.AlertDescription.bad_certificate + => NetworkErrorCodes.TlsCertHostnameMismatch, + Org.BouncyCastle.Tls.AlertDescription.unknown_ca + => NetworkErrorCodes.TlsCertUntrusted, + Org.BouncyCastle.Tls.AlertDescription.certificate_unknown + => NetworkErrorCodes.TlsCertInvalid, + Org.BouncyCastle.Tls.AlertDescription.certificate_required + => NetworkErrorCodes.TlsCertInvalid, + Org.BouncyCastle.Tls.AlertDescription.certificate_revoked + => NetworkErrorCodes.TlsCertInvalid, + _ => NetworkErrorCodes.TlsHandshakeFailure + }; + } + } + + return NetworkErrorCodes.TlsHandshakeFailure; + } } } diff --git a/src/Fluxzy.Core/Clients/Ssl/SChannel/SChannelConnectionBuilder.cs b/src/Fluxzy.Core/Clients/Ssl/SChannel/SChannelConnectionBuilder.cs index e7cda871f..c3497c7f7 100644 --- a/src/Fluxzy.Core/Clients/Ssl/SChannel/SChannelConnectionBuilder.cs +++ b/src/Fluxzy.Core/Clients/Ssl/SChannel/SChannelConnectionBuilder.cs @@ -3,8 +3,11 @@ using System; using System.IO; using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; +using Fluxzy.Core; namespace Fluxzy.Clients.Ssl.SChannel { @@ -18,14 +21,74 @@ public async Task AuthenticateAsClient( var sslOptions = builderOptions.GetSslClientAuthenticationOptions(); - await sslStream.AuthenticateAsClientAsync(sslOptions, token).ConfigureAwait(false); + // Install a sniffing validation callback so that, if AuthenticateAsClientAsync + // throws, we can map the failure to a specific NetworkErrorCodes token. The + // sniffer wraps any user-supplied callback (or the default policy) and only + // observes — it does not change the accept/reject decision. + var inner = sslOptions.RemoteCertificateValidationCallback; + SslPolicyErrors capturedPolicyErrors = SslPolicyErrors.None; + X509ChainStatusFlags capturedChainStatus = X509ChainStatusFlags.NoError; + var captured = false; - var exportCertificate = + sslOptions.RemoteCertificateValidationCallback = (sender, cert, chain, errors) => { + capturedPolicyErrors = errors; + if (chain != null) { + foreach (var status in chain.ChainStatus) { + capturedChainStatus |= status.Status; + } + } + captured = true; + return inner != null ? inner(sender, cert, chain, errors) : errors == SslPolicyErrors.None; + }; + + try { + await sslStream.AuthenticateAsClientAsync(sslOptions, token).ConfigureAwait(false); + } + catch (AuthenticationException ex) { + var networkErrorCode = captured + ? MapPolicyToNetworkErrorCode(capturedPolicyErrors, capturedChainStatus) + : NetworkErrorCodes.TlsHandshakeFailure; + + throw new ClientErrorException(0, + $"Handshake with {builderOptions.TargetHost} has failed", + innerMessageException: ex.Message, + innerException: ex, + networkErrorCode: networkErrorCode); + } + + var exportCertificate = builderOptions.AdvancedTlsSettings?.ExportCertificateInSslInfo ?? false; var sslInfo = new SslInfo(sslStream, exportCertificate); return new SslConnection(sslStream, sslInfo, sslStream.NegotiatedApplicationProtocol); } + + internal static string MapPolicyToNetworkErrorCode( + SslPolicyErrors policyErrors, + X509ChainStatusFlags chainStatus) + { + if ((policyErrors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0) { + return NetworkErrorCodes.TlsCertHostnameMismatch; + } + + if ((chainStatus & X509ChainStatusFlags.NotTimeValid) != 0 + || (chainStatus & X509ChainStatusFlags.CtlNotTimeValid) != 0) { + return NetworkErrorCodes.TlsCertExpired; + } + + if ((chainStatus & X509ChainStatusFlags.UntrustedRoot) != 0 + || (chainStatus & X509ChainStatusFlags.PartialChain) != 0 + || (chainStatus & X509ChainStatusFlags.NoIssuanceChainPolicy) != 0) { + return NetworkErrorCodes.TlsCertUntrusted; + } + + if ((policyErrors & SslPolicyErrors.RemoteCertificateChainErrors) != 0 + || (policyErrors & SslPolicyErrors.RemoteCertificateNotAvailable) != 0) { + return NetworkErrorCodes.TlsCertInvalid; + } + + return NetworkErrorCodes.TlsHandshakeFailure; + } } } diff --git a/src/Fluxzy.Core/Core/ClientErrorException.cs b/src/Fluxzy.Core/Core/ClientErrorException.cs index 4bd9c20c0..3aa47796a 100644 --- a/src/Fluxzy.Core/Core/ClientErrorException.cs +++ b/src/Fluxzy.Core/Core/ClientErrorException.cs @@ -6,11 +6,13 @@ namespace Fluxzy.Core { public class ClientErrorException : Exception { - public ClientErrorException(int errorCode, string message, string? innerMessageException = null, - Exception ? innerException = null) + public ClientErrorException(int errorCode, string message, + string? innerMessageException = null, + Exception? innerException = null, + string? networkErrorCode = null) : base(message, innerException) { - ClientError = new ClientError(errorCode, message) { + ClientError = new ClientError(errorCode, message, networkErrorCode) { ExceptionMessage = innerMessageException }; } diff --git a/src/Fluxzy.Core/Core/ConnectionErrorHandler.cs b/src/Fluxzy.Core/Core/ConnectionErrorHandler.cs index 1e4944b52..67ac18c84 100644 --- a/src/Fluxzy.Core/Core/ConnectionErrorHandler.cs +++ b/src/Fluxzy.Core/Core/ConnectionErrorHandler.cs @@ -1,246 +1,294 @@ -// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak - -using System; -using System.IO; -using System.Linq; -using System.Net.Sockets; -using System.Security.Authentication; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Fluxzy.Clients; -using Fluxzy.Clients.H2; -using Fluxzy.Clients.H2.Encoder; -using Fluxzy.Misc.ResizableBuffers; -using Fluxzy.Rules; -using Fluxzy.Writers; - -namespace Fluxzy.Core -{ - internal static class ConnectionErrorHandler - { - private static readonly JsonSerializerOptions PrettyJsonOptions = new JsonSerializerOptions { WriteIndented = true }; - - public static bool RequalifyOnResponseSendError( - Exception ex, - Exchange exchange, ITimingProvider timingProvider) - { - // Filling client error - - var extraHeaders = new StringBuilder(); - - if (exchange.Metrics.ResponseBodyEnd == default) { - exchange.Metrics.ResponseBodyEnd = timingProvider.Instant(); - } - - var remoteIpAddress = exchange.Connection?.RemoteAddress?.ToString(); - - if (ex.TryGetException(out var socketException)) { - switch (socketException.SocketErrorCode) { - case SocketError.ConnectionReset: { - var clientError = new ClientError( - (int) socketException.SocketErrorCode, - $"The connection was reset by remote peer {remoteIpAddress}.") { - ExceptionMessage = socketException.Message - }; - - exchange.ClientErrors.Add(clientError); - - break; - } - - case SocketError.TimedOut: { - var clientError = new ClientError( - (int) socketException.SocketErrorCode, - $"The remote peer ({remoteIpAddress}) " + - $"could not be contacted within the configured timeout on the port {exchange.Authority.Port}.") { - ExceptionMessage = socketException.Message - }; - - exchange.ClientErrors.Add(clientError); - - break; - } - - case SocketError.ConnectionRefused: { - var clientError = new ClientError( - (int) socketException.SocketErrorCode, - $"The remote peer ({remoteIpAddress}) responded but refused actively to establish a connection.") { - ExceptionMessage = socketException.Message - }; - - exchange.ClientErrors.Add(clientError); - - break; - } - - default: { - var clientError = new ClientError( - (int) socketException.SocketErrorCode, - "A socket exception has occured") { - ExceptionMessage = socketException.Message - }; - - exchange.ClientErrors.Add(clientError); - - break; - } - } - } - - if (ex.TryGetException(out var clientErrorException)) { - exchange.ClientErrors.Add(clientErrorException.ClientError); - } - - if (ex.TryGetException(out var ruleExecutionFailureException)) { - exchange.ClientErrors.Add(new ClientError(999, ruleExecutionFailureException.Message)); - } - - if (!exchange.ClientErrors.Any()) { - - extraHeaders.Append($"x-fluxzy-error-code: 0\r\n"); - extraHeaders.Append($"x-fluxzy-error-message: {ExceptionUtils.SanitizeHeaderValue(ex.Message)}\r\n"); - - - exchange.ClientErrors.Add(new ClientError(0, ex.Message) { - ExceptionMessage = ex.Message - }); - } - - if (ex is SocketException || - ex is IOException || - ex is H2Exception || - ex is ClientErrorException || - ex is RuleExecutionFailureException || - ex is AuthenticationException) { - if (DebugContext.EnableDumpStackTraceOn502) { - var message = "Fluxzy close connection due to server connection errors.\r\n\r\n"; - - if (DebugContext.EnableDumpStackTraceOn502 && exchange.Request?.Header != null) { - message += exchange.Request.Header.GetHttp11Header().ToString(); - } - - if (DebugContext.EnableDumpStackTraceOn502) { - message += ex.ToString(); - } - - if (DebugContext.EnableDumpStackTraceOn502) { - exchange.Metrics.ErrorInstant = DateTime.Now; - message += "\r\n" + "\r\n" + JsonSerializer.Serialize(exchange.Metrics,PrettyJsonOptions); - } - - var messageBinary = Encoding.UTF8.GetBytes(message); - - var header = string.Format(ConnectionErrorConstants.Generic502, - messageBinary.Length); - - if (extraHeaders.Length > 0) { - header = header.Insert( - header.IndexOf("\r\n\r\n", StringComparison.Ordinal), - extraHeaders.ToString()); - } - - exchange.Response.Header = new ResponseHeader( - header.AsMemory(), - exchange.Authority.Secure, true); - - exchange.Response.Body = new MemoryStream(messageBinary); - - if (!exchange.ExchangeCompletionSource.Task.IsCompleted) { - exchange.ExchangeCompletionSource.TrySetResult(true); - } - - return true; - } - else { - var (header, body) = ConnectionErrorPageHelper.GetPrettyErrorPage( - exchange.Authority, - exchange.ClientErrors, - ex); - - if (extraHeaders.Length > 0) - { - header = header.Insert( - header.IndexOf("\r\n\r\n", StringComparison.Ordinal), - extraHeaders.ToString()); - } - - exchange.Response.Header = new ResponseHeader( - header.AsMemory(), - exchange.Authority.Secure, true); - - exchange.Response.Body = new MemoryStream(body); - - if (!exchange.ExchangeCompletionSource.Task.IsCompleted) { - exchange.ExchangeCompletionSource.TrySetResult(true); - } - - return true; - } - } - - return false; - } - - public static async Task HandleGenericException(Exception ex, - IDownStreamPipe? downStreamPipe, - Exchange? exchange, - RsBuffer buffer, - RealtimeArchiveWriter? archiveWriter, - ITimingProvider timingProvider, CancellationToken token) - { - if (exchange?.Connection == null || downStreamPipe == null || !downStreamPipe.CanWrite) - return false; - - var message = "A configuration error has occured.\r\n"; - - if (ex is RuleExecutionFailureException ruleException) { - message = - "A rule execution failure has occured.\r\n\r\n" + ruleException.Message; - } - - if (DebugContext.EnableDumpStackTraceOn502) - { - message += $"\r\n" + - $"Stacktrace:\r\n{ex}"; - } - - var (header, body) = ConnectionErrorPageHelper.GetSimplePlainTextResponse( - exchange.Authority, - message, ex.ToString()); - - exchange.ClientErrors.Add(new ClientError(9999, message)); - - exchange.Response.Header = new ResponseHeader( - header.AsMemory(), - exchange.Authority.Secure, true); - - exchange.Response.Body = new MemoryStream(body); - - exchange.Metrics.ResponseHeaderStart = timingProvider.Instant(); - - await downStreamPipe.WriteResponseHeader(exchange.Response.Header, buffer, true, exchange.StreamIdentifier, exchange.Request.Header.Method, token); - - exchange.Metrics.ResponseHeaderEnd = timingProvider.Instant(); - exchange.Metrics.ResponseBodyStart = timingProvider.Instant(); - - await downStreamPipe.WriteResponseBody(exchange.Response.Body, buffer, false, exchange.StreamIdentifier, exchange.Response, token); - - if (exchange.Metrics.ResponseBodyEnd == default) - { - exchange.Metrics.ResponseBodyEnd = timingProvider.Instant(); - } - - if (!exchange.ExchangeCompletionSource.Task.IsCompleted) - { - exchange.ExchangeCompletionSource.TrySetResult(true); - } - - archiveWriter?.Update(exchange, ArchiveUpdateType.AfterResponse, token); - - - return true; - } - } -} +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Fluxzy.Clients; +using Fluxzy.Clients.H2; +using Fluxzy.Clients.H2.Encoder; +using Fluxzy.Misc.ResizableBuffers; +using Fluxzy.Rules; +using Fluxzy.Writers; + +namespace Fluxzy.Core +{ + internal static class ConnectionErrorHandler + { + private static readonly JsonSerializerOptions PrettyJsonOptions = new JsonSerializerOptions { WriteIndented = true }; + + public static bool RequalifyOnResponseSendError( + Exception ex, + Exchange exchange, ITimingProvider timingProvider) + { + // Filling client error + + var extraHeaders = new StringBuilder(); + + if (exchange.Metrics.ResponseBodyEnd == default) { + exchange.Metrics.ResponseBodyEnd = timingProvider.Instant(); + } + + var remoteIpAddress = exchange.Connection?.RemoteAddress?.ToString(); + + if (ex.TryGetException(out var socketException)) { + var (message, socketErrorToken) = MapSocketError( + socketException.SocketErrorCode, remoteIpAddress, exchange.Authority.Port); + + exchange.ClientErrors.Add(new ClientError( + (int) socketException.SocketErrorCode, message, socketErrorToken) { + ExceptionMessage = socketException.Message + }); + } + + if (ex.TryGetException(out var clientErrorException)) { + exchange.ClientErrors.Add(clientErrorException.ClientError); + } + + if (ex.TryGetException(out var ruleExecutionFailureException)) { + exchange.ClientErrors.Add(new ClientError(999, ruleExecutionFailureException.Message, + NetworkErrorCodes.RuleFailure)); + } + + if (!exchange.ClientErrors.Any()) { + + extraHeaders.Append($"x-fluxzy-error-code: 0\r\n"); + extraHeaders.Append($"x-fluxzy-error-message: {ExceptionUtils.SanitizeHeaderValue(ex.Message)}\r\n"); + + + exchange.ClientErrors.Add(new ClientError(0, ex.Message, ResolveNetworkErrorCode(ex)) { + ExceptionMessage = ex.Message + }); + } + + // Always emit x-fluxzy-network-error so consumers can react programmatically. + var networkErrorCode = exchange.ClientErrors + .Select(e => e.NetworkErrorCode) + .FirstOrDefault(code => !string.IsNullOrEmpty(code)) + ?? ResolveNetworkErrorCode(ex); + + extraHeaders.Append($"x-fluxzy-network-error: {networkErrorCode}\r\n"); + + if (ex is SocketException || + ex is IOException || + ex is H2Exception || + ex is ClientErrorException || + ex is RuleExecutionFailureException || + ex is AuthenticationException) { + if (DebugContext.EnableDumpStackTraceOn502) { + var message = "Fluxzy close connection due to server connection errors.\r\n\r\n"; + + if (DebugContext.EnableDumpStackTraceOn502 && exchange.Request?.Header != null) { + message += exchange.Request.Header.GetHttp11Header().ToString(); + } + + if (DebugContext.EnableDumpStackTraceOn502) { + message += ex.ToString(); + } + + if (DebugContext.EnableDumpStackTraceOn502) { + exchange.Metrics.ErrorInstant = DateTime.Now; + message += "\r\n" + "\r\n" + JsonSerializer.Serialize(exchange.Metrics,PrettyJsonOptions); + } + + var messageBinary = Encoding.UTF8.GetBytes(message); + + var header = string.Format(ConnectionErrorConstants.Generic502, + messageBinary.Length); + + if (extraHeaders.Length > 0) { + // Insert after the final header line's CRLF, before the empty-line CRLF + // that separates headers from body — otherwise the new header gets + // glued onto the tail of the previous one and the parser merges them. + var idx = header.IndexOf("\r\n\r\n", StringComparison.Ordinal); + + header = header.Insert(idx + 2, extraHeaders.ToString()); + } + + exchange.Response.Header = new ResponseHeader( + header.AsMemory(), + exchange.Authority.Secure, true); + + exchange.Response.Body = new MemoryStream(messageBinary); + + if (!exchange.ExchangeCompletionSource.Task.IsCompleted) { + exchange.ExchangeCompletionSource.TrySetResult(true); + } + + return true; + } + else { + var (header, body) = ConnectionErrorPageHelper.GetPrettyErrorPage( + exchange.Authority, + exchange.ClientErrors, + ex); + + if (extraHeaders.Length > 0) + { + var idx = header.IndexOf("\r\n\r\n", StringComparison.Ordinal); + + header = header.Insert(idx + 2, extraHeaders.ToString()); + } + + exchange.Response.Header = new ResponseHeader( + header.AsMemory(), + exchange.Authority.Secure, true); + + exchange.Response.Body = new MemoryStream(body); + + if (!exchange.ExchangeCompletionSource.Task.IsCompleted) { + exchange.ExchangeCompletionSource.TrySetResult(true); + } + + return true; + } + } + + return false; + } + + public static async Task HandleGenericException(Exception ex, + IDownStreamPipe? downStreamPipe, + Exchange? exchange, + RsBuffer buffer, + RealtimeArchiveWriter? archiveWriter, + ITimingProvider timingProvider, CancellationToken token) + { + if (exchange?.Connection == null || downStreamPipe == null || !downStreamPipe.CanWrite) + return false; + + var message = "A configuration error has occured.\r\n"; + + if (ex is RuleExecutionFailureException ruleException) { + message = + "A rule execution failure has occured.\r\n\r\n" + ruleException.Message; + } + + if (DebugContext.EnableDumpStackTraceOn502) + { + message += $"\r\n" + + $"Stacktrace:\r\n{ex}"; + } + + var (header, body) = ConnectionErrorPageHelper.GetSimplePlainTextResponse( + exchange.Authority, + message, ex.ToString()); + + var networkErrorCode = ex is RuleExecutionFailureException + ? NetworkErrorCodes.RuleFailure + : NetworkErrorCodes.Unknown; + + var endOfHeaders = header.IndexOf("\r\n\r\n", StringComparison.Ordinal); + header = header.Insert(endOfHeaders + 2, $"x-fluxzy-network-error: {networkErrorCode}\r\n"); + + exchange.ClientErrors.Add(new ClientError(9999, message, networkErrorCode)); + + exchange.Response.Header = new ResponseHeader( + header.AsMemory(), + exchange.Authority.Secure, true); + + exchange.Response.Body = new MemoryStream(body); + + exchange.Metrics.ResponseHeaderStart = timingProvider.Instant(); + + await downStreamPipe.WriteResponseHeader(exchange.Response.Header, buffer, true, exchange.StreamIdentifier, exchange.Request.Header.Method, token); + + exchange.Metrics.ResponseHeaderEnd = timingProvider.Instant(); + exchange.Metrics.ResponseBodyStart = timingProvider.Instant(); + + await downStreamPipe.WriteResponseBody(exchange.Response.Body, buffer, false, exchange.StreamIdentifier, exchange.Response, token); + + if (exchange.Metrics.ResponseBodyEnd == default) + { + exchange.Metrics.ResponseBodyEnd = timingProvider.Instant(); + } + + if (!exchange.ExchangeCompletionSource.Task.IsCompleted) + { + exchange.ExchangeCompletionSource.TrySetResult(true); + } + + archiveWriter?.Update(exchange, ArchiveUpdateType.AfterResponse, token); + + + return true; + } + + internal static string ResolveNetworkErrorCode(Exception ex) + { + for (var current = ex; current != null; current = current.InnerException) { + if (current is ClientErrorException cee && !string.IsNullOrEmpty(cee.ClientError.NetworkErrorCode)) { + return cee.ClientError.NetworkErrorCode!; + } + + if (current is RuleExecutionFailureException) { + return NetworkErrorCodes.RuleFailure; + } + + if (current is AuthenticationException) { + return NetworkErrorCodes.TlsHandshakeFailure; + } + } + + return NetworkErrorCodes.Unknown; + } + + internal static (string Message, string NetworkErrorCode) MapSocketError( + SocketError code, string? remoteIpAddress, int port) + { + return code switch { + SocketError.ConnectionReset => ( + $"The connection was reset by remote peer {remoteIpAddress}.", + NetworkErrorCodes.ConnectionReset), + + // EPIPE on Linux: peer sent RST (or closed) and we then tried to write. + // .NET surfaces this as SocketError.Shutdown — semantically a reset by peer. + SocketError.Shutdown => ( + $"The connection was reset by remote peer {remoteIpAddress}.", + NetworkErrorCodes.ConnectionReset), + + SocketError.TimedOut => ( + $"The remote peer ({remoteIpAddress}) " + + $"could not be contacted within the configured timeout on the port {port}.", + NetworkErrorCodes.ConnectionTimeout), + + SocketError.ConnectionRefused => ( + $"The remote peer ({remoteIpAddress}) responded but refused actively to establish a connection.", + NetworkErrorCodes.ConnectionRefused), + + SocketError.ConnectionAborted => ( + $"The connection was aborted by remote peer {remoteIpAddress}.", + NetworkErrorCodes.ConnectionAborted), + + SocketError.HostUnreachable => ( + $"The remote host ({remoteIpAddress}) is unreachable.", + NetworkErrorCodes.HostUnreachable), + + SocketError.NetworkUnreachable => ( + "The remote network is unreachable.", + NetworkErrorCodes.NetworkUnreachable), + + SocketError.HostNotFound => ( + "DNS lookup failed: host not found.", + NetworkErrorCodes.DnsNotFound), + + SocketError.TryAgain => ( + "DNS lookup failed.", + NetworkErrorCodes.DnsTryAgain), + + SocketError.NoData => ( + "DNS lookup failed.", + NetworkErrorCodes.DnsNoData), + + _ => ( + "A socket exception has occured", + NetworkErrorCodes.Unknown) + }; + } + } +} diff --git a/src/Fluxzy.Core/Core/ConnectionErrorPageHelper.cs b/src/Fluxzy.Core/Core/ConnectionErrorPageHelper.cs index 809b11c47..cfed8d841 100644 --- a/src/Fluxzy.Core/Core/ConnectionErrorPageHelper.cs +++ b/src/Fluxzy.Core/Core/ConnectionErrorPageHelper.cs @@ -61,11 +61,22 @@ public static (string FlatHeader, byte[] BodyContent) GetPrettyErrorPage( errorMessage = originalException?.Message ?? "Internal fluxzy error."; } + var networkErrorCode = clientErrors + .Select(e => e.NetworkErrorCode) + .FirstOrDefault(c => !string.IsNullOrEmpty(c)); + + var networkErrorBlock = string.IsNullOrEmpty(networkErrorCode) + ? string.Empty + : "

Network error code: " + + "" + networkErrorCode + "

"; + var bodyTemplate = BodyTemplate; bodyTemplate = bodyTemplate.Replace("@@error-status-code@@", rawStatusCode); bodyTemplate = bodyTemplate.Replace("@@error-host@@", authority.ToString()); bodyTemplate = bodyTemplate.Replace("@@error-message@@", errorMessage); + bodyTemplate = bodyTemplate.Replace("@@network-error-code-block@@", networkErrorBlock); var body = Encoding.UTF8.GetBytes(bodyTemplate); var header = string.Format(ErrorHeaderHtml, headerStatus, body.Length); diff --git a/src/Fluxzy.Core/Core/NetworkErrorCodes.cs b/src/Fluxzy.Core/Core/NetworkErrorCodes.cs new file mode 100644 index 000000000..996dc3f5d --- /dev/null +++ b/src/Fluxzy.Core/Core/NetworkErrorCodes.cs @@ -0,0 +1,39 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +namespace Fluxzy.Core +{ + /// + /// Stable, machine-readable identifiers for the kind of upstream failure that + /// produced a synthesized 528 response. Emitted as x-fluxzy-network-error + /// on the response and persisted on . + /// + public static class NetworkErrorCodes + { + // Connection layer + public const string ConnectionRefused = "connection_refused"; + public const string ConnectionReset = "connection_reset"; + public const string ConnectionAborted = "connection_aborted"; + public const string ConnectionTimeout = "connection_timeout"; + public const string HostUnreachable = "host_unreachable"; + public const string NetworkUnreachable = "network_unreachable"; + public const string ConnectionClosed = "connection_closed"; + + // DNS layer + public const string DnsNotFound = "dns_notfound"; + public const string DnsNoData = "dns_no_data"; + public const string DnsTryAgain = "dns_try_again"; + public const string DnsFailure = "dns_failure"; + + // TLS layer + public const string TlsCertExpired = "tls_cert_expired"; + public const string TlsCertHostnameMismatch = "tls_cert_hostname_mismatch"; + public const string TlsCertUntrusted = "tls_cert_untrusted"; + public const string TlsCertInvalid = "tls_cert_invalid"; + public const string TlsHandshakeFailure = "tls_handshake_failure"; + + // Other + public const string ProtocolError = "protocol_error"; + public const string RuleFailure = "rule_failure"; + public const string Unknown = "unknown"; + } +} diff --git a/src/Fluxzy.Core/Resources/Pages/error.html b/src/Fluxzy.Core/Resources/Pages/error.html index 33871f513..7a28bcb9e 100644 --- a/src/Fluxzy.Core/Resources/Pages/error.html +++ b/src/Fluxzy.Core/Resources/Pages/error.html @@ -58,7 +58,7 @@

@@error-message@@

- + @@network-error-code-block@@

diff --git a/test/Fluxzy.Tests/Cases/BadRuleHandlingTests.cs b/test/Fluxzy.Tests/Cases/BadRuleHandlingTests.cs index efb62f6e7..9a3a7ba06 100644 --- a/test/Fluxzy.Tests/Cases/BadRuleHandlingTests.cs +++ b/test/Fluxzy.Tests/Cases/BadRuleHandlingTests.cs @@ -3,6 +3,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using Fluxzy.Core; using Fluxzy.Rules; using Fluxzy.Rules.Actions; using Fluxzy.Rules.Extensions; @@ -55,6 +56,11 @@ private async Task HandleGeneric(Action builder) Assert.True(hasHeader); Assert.NotNull(values); Assert.Contains(nameof(RuleExecutionFailureException), values.First()); + + Assert.True( + response.Headers.TryGetValues("x-fluxzy-network-error", out var networkErrorValues), + "Response is missing the x-fluxzy-network-error header."); + Assert.Equal(NetworkErrorCodes.RuleFailure, networkErrorValues!.Single()); } } } diff --git a/test/Fluxzy.Tests/Cases/Extended528Tests.cs b/test/Fluxzy.Tests/Cases/Extended528Tests.cs index 15c260c00..d4a5e1e20 100644 --- a/test/Fluxzy.Tests/Cases/Extended528Tests.cs +++ b/test/Fluxzy.Tests/Cases/Extended528Tests.cs @@ -2,7 +2,10 @@ using System; using System.Linq; +using System.Net.Http; using System.Threading.Tasks; +using Fluxzy.Core; +using Fluxzy.Tests._Fixtures; using Xunit; namespace Fluxzy.Tests.Cases @@ -14,65 +17,145 @@ public class Extended528Tests [InlineData(false)] public async Task Validate_Expired_Ssl(bool useBouncyCastle) { - var setting = FluxzySetting.CreateLocalRandomPort(); + var response = await Run("https://expired.badssl.com/", useBouncyCastle); - setting.UseBouncyCastle = useBouncyCastle; + Assert.Equal(528, (int) response.StatusCode); + AssertNetworkErrorCode(response, NetworkErrorCodes.TlsCertExpired); + } - await using var proxy = new Proxy(setting); + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Validate_Wrong_Host(bool useBouncyCastle) + { + var response = await Run("https://wrong.host.badssl.com/", useBouncyCastle); - var endPoints = proxy.Run(); + Assert.Equal(528, (int) response.StatusCode); + AssertNetworkErrorCode(response, NetworkErrorCodes.TlsCertHostnameMismatch); + } - var httpClient = HttpClientUtility.CreateHttpClient(endPoints, setting); + // Trust-chain validation is OS-stack only: BouncyCastle's FluxzyTlsAuthentication + // currently validates dates and hostname but does not verify the issuance chain, + // so untrusted-root / self-signed scenarios go through unblocked under BC. + [Fact] + public async Task Validate_Untrusted_Root_Os() + { + var response = await Run("https://untrusted-root.badssl.com/", useBouncyCastle: false); - var response = await httpClient.GetAsync("https://expired.badssl.com/"); + Assert.Equal(528, (int) response.StatusCode); + AssertNetworkErrorCode(response, NetworkErrorCodes.TlsCertUntrusted); + } - _ = await response.Content.ReadAsStringAsync(); + [Fact] + public async Task Validate_Self_Signed_Os() + { + var response = await Run("https://self-signed.badssl.com/", useBouncyCastle: false); Assert.Equal(528, (int) response.StatusCode); + AssertNetworkErrorCode(response, NetworkErrorCodes.TlsCertUntrusted); } [Theory] [InlineData(true)] [InlineData(false)] - public async Task Validate_Wrong_Host(bool useBouncyCastle) + public async Task Validate_Connection_Refused(bool useBouncyCastle) { - var setting = FluxzySetting.CreateLocalRandomPort(); + // 127.0.0.1:1 is virtually guaranteed to refuse — nothing listens on TCP/1. + var response = await Run("http://127.0.0.1:1/", useBouncyCastle); - setting.UseBouncyCastle = useBouncyCastle; + Assert.Equal(528, (int) response.StatusCode); + AssertNetworkErrorCode(response, NetworkErrorCodes.ConnectionRefused); + } - await using var proxy = new Proxy(setting); + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Validate_Dns_NotFound(bool useBouncyCastle) + { + // .invalid TLD is reserved by RFC 6761 to never resolve. + var response = await Run( + "http://this.host.does.not.exist.fluxzy.invalid/", useBouncyCastle); - var endPoints = proxy.Run(); + Assert.Equal(528, (int) response.StatusCode); + AssertNetworkErrorCode(response, NetworkErrorCodes.DnsNotFound); + } - var httpClient = HttpClientUtility.CreateHttpClient(endPoints, setting); + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Validate_With_Ip_Address(bool useBouncyCastle) + { + var response = await Run("https://1.1.1.1", useBouncyCastle); - var response = await httpClient.GetAsync("https://wrong.host.badssl.com/"); + Assert.NotEqual(528, (int) response.StatusCode); + } - _ = await response.Content.ReadAsStringAsync(); + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Validate_Abrupt_Close(bool useBouncyCastle) + { + // Server sends an RST. The exact surfacing depends on the OS/stack and + // the race between our write and the peer's RST: + // - write hit before kernel saw RST → ECONNRESET / ECONNABORTED + // - write hit after kernel saw RST → EPIPE on Linux (Shutdown → ConnectionReset) + // - write succeeded, read sees EOF → ConnectionClosed (no SocketException) + // All three are correct surfacings of the same upstream behaviour. + await using var server = MisbehavingTcpServer.Start(MisbehaveMode.AbruptClose); + var response = await Run($"http://127.0.0.1:{server.Port}/", useBouncyCastle); Assert.Equal(528, (int) response.StatusCode); + AssertNetworkErrorCodeOneOf(response, + NetworkErrorCodes.ConnectionReset, + NetworkErrorCodes.ConnectionAborted, + NetworkErrorCodes.ConnectionClosed); } [Theory] [InlineData(true)] [InlineData(false)] - public async Task Validate_With_Ip_Address(bool useBouncyCastle) + public async Task Validate_Tls_Handshake_Failure(bool useBouncyCastle) { - var setting = FluxzySetting.CreateLocalRandomPort(); + // Server replies with a TLS fatal alert (handshake_failure) so the proxy's + // upstream TLS engine fails the handshake instead of seeing an abrupt close. + await using var server = MisbehavingTcpServer.Start(MisbehaveMode.SendTlsHandshakeFailureAlert); + var response = await Run($"https://127.0.0.1:{server.Port}/", useBouncyCastle); + Assert.Equal(528, (int) response.StatusCode); + AssertNetworkErrorCode(response, NetworkErrorCodes.TlsHandshakeFailure); + } + + private static async Task Run(string url, bool useBouncyCastle) + { + var setting = FluxzySetting.CreateLocalRandomPort(); setting.UseBouncyCastle = useBouncyCastle; await using var proxy = new Proxy(setting); - var endPoints = proxy.Run(); var httpClient = HttpClientUtility.CreateHttpClient(endPoints, setting); + var response = await httpClient.GetAsync(url); + _ = await response.Content.ReadAsStringAsync(); + return response; + } - var response = await httpClient.GetAsync("https://1.1.1.1"); + private static void AssertNetworkErrorCode(HttpResponseMessage response, string expectedToken) + { + Assert.True( + response.Headers.TryGetValues("x-fluxzy-network-error", out var values), + "Response is missing the x-fluxzy-network-error header."); - _ = await response.Content.ReadAsStringAsync(); + Assert.Equal(expectedToken, values!.Single(), StringComparer.OrdinalIgnoreCase); + } - Assert.NotEqual(528, (int) response.StatusCode); + private static void AssertNetworkErrorCodeOneOf(HttpResponseMessage response, params string[] acceptedTokens) + { + Assert.True( + response.Headers.TryGetValues("x-fluxzy-network-error", out var values), + "Response is missing the x-fluxzy-network-error header."); + + var actual = values!.Single(); + Assert.Contains(actual, acceptedTokens, StringComparer.OrdinalIgnoreCase); } } } diff --git a/test/Fluxzy.Tests/UnitTests/Core/NetworkErrorCodesTests.cs b/test/Fluxzy.Tests/UnitTests/Core/NetworkErrorCodesTests.cs new file mode 100644 index 000000000..f4be421a2 --- /dev/null +++ b/test/Fluxzy.Tests/UnitTests/Core/NetworkErrorCodesTests.cs @@ -0,0 +1,35 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using Fluxzy.Core; +using Xunit; + +namespace Fluxzy.Tests.UnitTests.Core +{ + public class NetworkErrorCodesTests + { + [Theory] + [InlineData("connection_refused", NetworkErrorCodes.ConnectionRefused)] + [InlineData("connection_reset", NetworkErrorCodes.ConnectionReset)] + [InlineData("connection_aborted", NetworkErrorCodes.ConnectionAborted)] + [InlineData("connection_timeout", NetworkErrorCodes.ConnectionTimeout)] + [InlineData("host_unreachable", NetworkErrorCodes.HostUnreachable)] + [InlineData("network_unreachable", NetworkErrorCodes.NetworkUnreachable)] + [InlineData("connection_closed", NetworkErrorCodes.ConnectionClosed)] + [InlineData("dns_notfound", NetworkErrorCodes.DnsNotFound)] + [InlineData("dns_no_data", NetworkErrorCodes.DnsNoData)] + [InlineData("dns_try_again", NetworkErrorCodes.DnsTryAgain)] + [InlineData("dns_failure", NetworkErrorCodes.DnsFailure)] + [InlineData("tls_cert_expired", NetworkErrorCodes.TlsCertExpired)] + [InlineData("tls_cert_hostname_mismatch", NetworkErrorCodes.TlsCertHostnameMismatch)] + [InlineData("tls_cert_untrusted", NetworkErrorCodes.TlsCertUntrusted)] + [InlineData("tls_cert_invalid", NetworkErrorCodes.TlsCertInvalid)] + [InlineData("tls_handshake_failure", NetworkErrorCodes.TlsHandshakeFailure)] + [InlineData("protocol_error", NetworkErrorCodes.ProtocolError)] + [InlineData("rule_failure", NetworkErrorCodes.RuleFailure)] + [InlineData("unknown", NetworkErrorCodes.Unknown)] + public void Token_Strings_Are_Stable(string expected, string actual) + { + Assert.Equal(expected, actual); + } + } +} diff --git a/test/Fluxzy.Tests/UnitTests/Core/NetworkErrorMappingTests.cs b/test/Fluxzy.Tests/UnitTests/Core/NetworkErrorMappingTests.cs new file mode 100644 index 000000000..a3934f0d2 --- /dev/null +++ b/test/Fluxzy.Tests/UnitTests/Core/NetworkErrorMappingTests.cs @@ -0,0 +1,104 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using Fluxzy.Clients.Ssl.BouncyCastle; +using Fluxzy.Clients.Ssl.SChannel; +using Fluxzy.Core; +using Fluxzy.Rules; +using Org.BouncyCastle.Tls; +using Xunit; + +namespace Fluxzy.Tests.UnitTests.Core +{ + public class NetworkErrorMappingTests + { + [Theory] + [InlineData((short) AlertDescription.certificate_expired, NetworkErrorCodes.TlsCertExpired)] + [InlineData((short) AlertDescription.bad_certificate, NetworkErrorCodes.TlsCertHostnameMismatch)] + [InlineData((short) AlertDescription.unknown_ca, NetworkErrorCodes.TlsCertUntrusted)] + [InlineData((short) AlertDescription.certificate_unknown, NetworkErrorCodes.TlsCertInvalid)] + [InlineData((short) AlertDescription.certificate_required, NetworkErrorCodes.TlsCertInvalid)] + [InlineData((short) AlertDescription.certificate_revoked, NetworkErrorCodes.TlsCertInvalid)] + [InlineData((short) AlertDescription.handshake_failure, NetworkErrorCodes.TlsHandshakeFailure)] + [InlineData((short) AlertDescription.internal_error, NetworkErrorCodes.TlsHandshakeFailure)] + public void BouncyCastle_MapTlsAlert_Returns_Expected_Token(short alertDescription, string expected) + { + var alert = new TlsFatalAlert(alertDescription); + Assert.Equal(expected, BouncyCastleConnectionBuilder.MapTlsAlert(alert)); + } + + [Fact] + public void BouncyCastle_MapTlsAlert_Walks_InnerException_Chain() + { + var inner = new TlsFatalAlert(AlertDescription.certificate_expired); + var wrapper = new InvalidOperationException("wrapper", inner); + + Assert.Equal(NetworkErrorCodes.TlsCertExpired, BouncyCastleConnectionBuilder.MapTlsAlert(wrapper)); + } + + [Fact] + public void BouncyCastle_MapTlsAlert_Falls_Back_When_No_Alert_Found() + { + var ex = new InvalidOperationException("no alert in chain"); + Assert.Equal(NetworkErrorCodes.TlsHandshakeFailure, BouncyCastleConnectionBuilder.MapTlsAlert(ex)); + } + + [Theory] + [InlineData(SslPolicyErrors.RemoteCertificateNameMismatch, X509ChainStatusFlags.NoError, NetworkErrorCodes.TlsCertHostnameMismatch)] + [InlineData(SslPolicyErrors.RemoteCertificateChainErrors, X509ChainStatusFlags.NotTimeValid, NetworkErrorCodes.TlsCertExpired)] + [InlineData(SslPolicyErrors.RemoteCertificateChainErrors, X509ChainStatusFlags.UntrustedRoot, NetworkErrorCodes.TlsCertUntrusted)] + [InlineData(SslPolicyErrors.RemoteCertificateChainErrors, X509ChainStatusFlags.PartialChain, NetworkErrorCodes.TlsCertUntrusted)] + [InlineData(SslPolicyErrors.RemoteCertificateChainErrors, X509ChainStatusFlags.NoIssuanceChainPolicy, NetworkErrorCodes.TlsCertUntrusted)] + [InlineData(SslPolicyErrors.RemoteCertificateChainErrors, X509ChainStatusFlags.Revoked, NetworkErrorCodes.TlsCertInvalid)] + [InlineData(SslPolicyErrors.RemoteCertificateNotAvailable, X509ChainStatusFlags.NoError, NetworkErrorCodes.TlsCertInvalid)] + [InlineData(SslPolicyErrors.None, X509ChainStatusFlags.NoError, NetworkErrorCodes.TlsHandshakeFailure)] + public void SChannel_MapPolicy_Returns_Expected_Token( + SslPolicyErrors policy, X509ChainStatusFlags chain, string expected) + { + Assert.Equal(expected, SChannelConnectionBuilder.MapPolicyToNetworkErrorCode(policy, chain)); + } + + [Fact] + public void Resolve_Returns_Unknown_For_Plain_Exception() + { + var ex = new InvalidOperationException("nothing special"); + Assert.Equal(NetworkErrorCodes.Unknown, ConnectionErrorHandler.ResolveNetworkErrorCode(ex)); + } + + [Fact] + public void Resolve_Returns_TlsHandshakeFailure_For_AuthenticationException() + { + var ex = new AuthenticationException("tls failed"); + Assert.Equal(NetworkErrorCodes.TlsHandshakeFailure, ConnectionErrorHandler.ResolveNetworkErrorCode(ex)); + } + + [Fact] + public void Resolve_Returns_RuleFailure_For_RuleExecutionFailureException() + { + var ex = new RuleExecutionFailureException("rule kaboom", new InvalidOperationException("inner")); + Assert.Equal(NetworkErrorCodes.RuleFailure, ConnectionErrorHandler.ResolveNetworkErrorCode(ex)); + } + + [Theory] + [InlineData(NetworkErrorCodes.ProtocolError)] + [InlineData(NetworkErrorCodes.ConnectionClosed)] + [InlineData(NetworkErrorCodes.DnsFailure)] + public void Resolve_Honours_PreTagged_ClientErrorException(string token) + { + var ex = new ClientErrorException(0, "msg", networkErrorCode: token); + Assert.Equal(token, ConnectionErrorHandler.ResolveNetworkErrorCode(ex)); + } + + [Fact] + public void Resolve_Walks_InnerException_Chain() + { + var inner = new ClientErrorException(0, "inner", networkErrorCode: NetworkErrorCodes.ProtocolError); + var outer = new InvalidOperationException("outer", inner); + + Assert.Equal(NetworkErrorCodes.ProtocolError, ConnectionErrorHandler.ResolveNetworkErrorCode(outer)); + } + } +} diff --git a/test/Fluxzy.Tests/UnitTests/Core/SocketErrorMappingTests.cs b/test/Fluxzy.Tests/UnitTests/Core/SocketErrorMappingTests.cs new file mode 100644 index 000000000..b5d533973 --- /dev/null +++ b/test/Fluxzy.Tests/UnitTests/Core/SocketErrorMappingTests.cs @@ -0,0 +1,46 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System.Net.Sockets; +using Fluxzy.Core; +using Xunit; + +namespace Fluxzy.Tests.UnitTests.Core +{ + public class SocketErrorMappingTests + { + [Theory] + [InlineData(SocketError.ConnectionRefused, NetworkErrorCodes.ConnectionRefused)] + [InlineData(SocketError.ConnectionReset, NetworkErrorCodes.ConnectionReset)] + [InlineData(SocketError.ConnectionAborted, NetworkErrorCodes.ConnectionAborted)] + [InlineData(SocketError.Shutdown, NetworkErrorCodes.ConnectionReset)] + [InlineData(SocketError.TimedOut, NetworkErrorCodes.ConnectionTimeout)] + [InlineData(SocketError.HostUnreachable, NetworkErrorCodes.HostUnreachable)] + [InlineData(SocketError.NetworkUnreachable, NetworkErrorCodes.NetworkUnreachable)] + [InlineData(SocketError.HostNotFound, NetworkErrorCodes.DnsNotFound)] + [InlineData(SocketError.NoData, NetworkErrorCodes.DnsNoData)] + [InlineData(SocketError.TryAgain, NetworkErrorCodes.DnsTryAgain)] + [InlineData(SocketError.AccessDenied, NetworkErrorCodes.Unknown)] + [InlineData(SocketError.Fault, NetworkErrorCodes.Unknown)] + public void MapSocketError_Returns_Expected_Token(SocketError code, string expected) + { + var (_, token) = ConnectionErrorHandler.MapSocketError(code, "1.2.3.4", 443); + Assert.Equal(expected, token); + } + + [Theory] + [InlineData(SocketError.ConnectionRefused)] + [InlineData(SocketError.ConnectionReset)] + [InlineData(SocketError.ConnectionAborted)] + [InlineData(SocketError.TimedOut)] + [InlineData(SocketError.HostUnreachable)] + [InlineData(SocketError.NetworkUnreachable)] + [InlineData(SocketError.HostNotFound)] + [InlineData(SocketError.NoData)] + [InlineData(SocketError.TryAgain)] + public void MapSocketError_Returns_NonEmpty_Message(SocketError code) + { + var (message, _) = ConnectionErrorHandler.MapSocketError(code, "1.2.3.4", 443); + Assert.False(string.IsNullOrWhiteSpace(message)); + } + } +} diff --git a/test/Fluxzy.Tests/UnitTests/Equality/ClientErrorEquality.cs b/test/Fluxzy.Tests/UnitTests/Equality/ClientErrorEquality.cs index 6fb85797c..28caf6c9a 100644 --- a/test/Fluxzy.Tests/UnitTests/Equality/ClientErrorEquality.cs +++ b/test/Fluxzy.Tests/UnitTests/Equality/ClientErrorEquality.cs @@ -15,6 +15,7 @@ public class ClientErrorEquality : EqualityTesterBase = new[] { new ClientError(9, "messages"), new ClientError(90, "message"), + new ClientError(9, "message", "connection_refused"), }; } } diff --git a/test/Fluxzy.Tests/UnitTests/Misc/DefaultDnsResolverTests.cs b/test/Fluxzy.Tests/UnitTests/Misc/DefaultDnsResolverTests.cs index fe123c183..1fd0ea0b6 100644 --- a/test/Fluxzy.Tests/UnitTests/Misc/DefaultDnsResolverTests.cs +++ b/test/Fluxzy.Tests/UnitTests/Misc/DefaultDnsResolverTests.cs @@ -1,6 +1,7 @@ // Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading.Tasks; using Fluxzy.Clients.Dns; @@ -21,12 +22,13 @@ public async Task Solve(string host, string ? rawIp) if (rawIp != null) { - var ip = await solver.SolveDns(host); + var ip = await solver.SolveDns(host); Assert.Equal(IPAddress.Parse(rawIp), ip); } else { - await Assert.ThrowsAsync(() => solver.SolveDns(host)); + var ex = await Assert.ThrowsAsync(() => solver.SolveDns(host)); + Assert.Equal(NetworkErrorCodes.DnsNotFound, ex.ClientError.NetworkErrorCode); } } @@ -113,5 +115,34 @@ async Task Validate() await Assert.ThrowsAsync(() => solver.SolveDns(host)); } } + + [Fact] + public async Task SolveDns_Sets_DnsNoData_When_Resolver_Returns_Empty() + { + var solver = new EmptyResultResolver(); + + var ex = await Assert.ThrowsAsync( + () => solver.SolveDns("probe.example")); + + Assert.Equal(NetworkErrorCodes.DnsNoData, ex.ClientError.NetworkErrorCode); + } + + [Theory] + [InlineData(System.Net.Sockets.SocketError.HostNotFound, NetworkErrorCodes.DnsNotFound)] + [InlineData(System.Net.Sockets.SocketError.NoData, NetworkErrorCodes.DnsNoData)] + [InlineData(System.Net.Sockets.SocketError.TryAgain, NetworkErrorCodes.DnsTryAgain)] + [InlineData(System.Net.Sockets.SocketError.NoRecovery, NetworkErrorCodes.DnsFailure)] + [InlineData(System.Net.Sockets.SocketError.AccessDenied, NetworkErrorCodes.DnsFailure)] + public void MapDnsSocketError_Returns_Expected_Token( + System.Net.Sockets.SocketError code, string expected) + { + Assert.Equal(expected, DefaultDnsResolver.MapDnsSocketError(code)); + } + + private sealed class EmptyResultResolver : DefaultDnsResolver + { + protected override Task> InternalSolveDns(string hostName) + => Task.FromResult(Enumerable.Empty()); + } } } diff --git a/test/Fluxzy.Tests/_Fixtures/MisbehavingTcpServer.cs b/test/Fluxzy.Tests/_Fixtures/MisbehavingTcpServer.cs new file mode 100644 index 000000000..418f71907 --- /dev/null +++ b/test/Fluxzy.Tests/_Fixtures/MisbehavingTcpServer.cs @@ -0,0 +1,123 @@ +// Copyright 2021 - Haga Rakotoharivelo - https://github.com/haga-rak + +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Fluxzy.Tests._Fixtures +{ + internal enum MisbehaveMode + { + /// + /// Accept the TCP connection then close it with a zero-second linger. + /// Surfaces as on some stacks + /// and on others — both are + /// abrupt-close tokens. + /// + AbruptClose, + + /// + /// Accept the TCP connection, read the TLS ClientHello, then reply with + /// a fatal TLS alert (handshake_failure) and close. Triggers a real TLS + /// handshake failure on the peer instead of a TCP-level abort. + /// + SendTlsHandshakeFailureAlert + } + + internal sealed class MisbehavingTcpServer : IAsyncDisposable + { + private readonly TcpListener _listener; + private readonly MisbehaveMode _mode; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _loop; + + private MisbehavingTcpServer(TcpListener listener, MisbehaveMode mode) + { + _listener = listener; + _mode = mode; + _loop = AcceptLoop(); + } + + public int Port => ((IPEndPoint) _listener.LocalEndpoint).Port; + + public static MisbehavingTcpServer Start(MisbehaveMode mode) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return new MisbehavingTcpServer(listener, mode); + } + + private async Task AcceptLoop() + { + try { + while (!_cts.IsCancellationRequested) { + var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false); + _ = HandleClient(client); + } + } + catch { + // shutting down + } + } + + private async Task HandleClient(TcpClient client) + { + try { + switch (_mode) { + case MisbehaveMode.AbruptClose: + client.LingerState = new LingerOption(true, 0); + client.Close(); + break; + + case MisbehaveMode.SendTlsHandshakeFailureAlert: { + var stream = client.GetStream(); + var buffer = new byte[4096]; + + try { + // Drain the ClientHello (we don't actually parse it). + await stream.ReadAsync(buffer, _cts.Token).ConfigureAwait(false); + } + catch { + // ignore + } + + // TLS Alert record: 21 (alert), 03 03 (TLS 1.2), 00 02 (length), + // 02 (fatal), 28 (handshake_failure = 40). + var alert = new byte[] { 0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 40 }; + + try { + await stream.WriteAsync(alert, _cts.Token).ConfigureAwait(false); + await stream.FlushAsync(_cts.Token).ConfigureAwait(false); + } + catch { + // ignore + } + + client.Close(); + break; + } + } + } + catch { + // best effort + } + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + _listener.Stop(); + + try { + await _loop.ConfigureAwait(false); + } + catch { + // ignore + } + + _cts.Dispose(); + } + } +}