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();
+ }
+ }
+}