Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .codegraph/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,6 @@
[TestClass]
public sealed class OpenSslSocketsHttpHandlerTests
{
[TestMethod]
public void Constructor_WithNullParameter_ShouldNotThrow()
{
// Arrange & Act
var handler = new OpenSslSocketsHttpHandler(null);

// Assert
Assert.IsNotNull(handler);
handler.Dispose();
}

[TestMethod]
public void Constructor_WithDefaultParameter_ShouldNotThrow()
{
Expand All @@ -25,52 +14,6 @@ public void Constructor_WithDefaultParameter_ShouldNotThrow()
handler.Dispose();
}

[TestMethod]
public void Constructor_WithFreshSocketsHttpHandler_ShouldNotThrow()
{
// Arrange
using var innerHandler = new SocketsHttpHandler();

// Act
var handler = new OpenSslSocketsHttpHandler(innerHandler);

// Assert
Assert.IsNotNull(handler);
handler.Dispose();
}

[TestMethod]
public void Constructor_WithProvidedHandler_SetsConnectCallback()
{
// Arrange
using var innerHandler = new SocketsHttpHandler();

// Act
var handler = new OpenSslSocketsHttpHandler(innerHandler);

// Assert
Assert.IsNotNull(innerHandler.ConnectCallback, "ConnectCallback should be set on provided handler.");

handler.Dispose();
}

[TestMethod]
public void Constructor_WithConnectCallbackAlreadySet_ShouldThrowArgumentException()
{
// Arrange
using var innerHandler = new SocketsHttpHandler();
innerHandler.ConnectCallback = static (context, cancellationToken) =>
ValueTask.FromResult<Stream>(Stream.Null);

// Act
void Act() => new OpenSslSocketsHttpHandler(innerHandler);

// Assert
var exception = Assert.Throws<ArgumentException>(Act);
Assert.AreEqual("socketsHttpHandler", exception.ParamName);
StringAssert.Contains(exception.Message, "ConnectCallback");
}

[TestMethod]
public void Dispose_ShouldDisposeWithoutException()
{
Expand Down
97 changes: 97 additions & 0 deletions src/DotNetCampus.HttpClientOverOpenSsl/ConnectionOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
namespace DotNetCampus.HttpClientOverOpenSsl
{
sealed class ConnectionOptions
{
private static readonly HttpRequestOptionsKey<ConnectionOptions> key = new(nameof(ConnectionOptions));

/// <summary>
/// 是否为安全传输
/// </summary>
public bool IsSecurity { get; }

/// <summary>
/// 原始请求Uri
/// </summary>
public Uri OriginalUri { get; }

/// <summary>
/// 连接选项
/// </summary>
/// <param name="isSecurity"></param>
/// <param name="originalUri"></param>
public ConnectionOptions(bool isSecurity, Uri originalUri)
{
this.IsSecurity = isSecurity;
this.OriginalUri = originalUri;
}


/// <summary>
/// 获取自定义连接选项
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
public static ConnectionOptions Get(HttpRequestMessage request)
{
return request.Options.TryGetValue(key, out var options)
? options
: throw new InvalidOperationException("必须先 Set()");
}

/// <summary>
/// 设置使用自定义连接
/// </summary>
/// <param name="request"></param>
public static void Set(HttpRequestMessage request)
{
if (request.Options.TryGetValue(key, out _))
{
return;
}

var originalUri = request.RequestUri ?? throw new HttpRequestException("必须指定请求的URI");
var isSecurity = originalUri.Scheme == Uri.UriSchemeHttps
|| originalUri.Scheme == Uri.UriSchemeWss
|| originalUri.Scheme == Uri.UriSchemeFtps;

if (isSecurity == true)
{
// 修改Scheme之前,记录原始的Host
if (request.Headers.Host == null)
{
request.Headers.Host = originalUri.Authority;
}

// 修改协议非安全Scheme防止自动ssl连接
if (originalUri.Scheme == Uri.UriSchemeHttps)
{
request.RequestUri = new UriBuilder(originalUri) { Scheme = Uri.UriSchemeHttp }.Uri;
}
else if (originalUri.Scheme == Uri.UriSchemeWss)
{
request.RequestUri = new UriBuilder(originalUri) { Scheme = Uri.UriSchemeWs }.Uri;
}
else if (originalUri.Scheme == Uri.UriSchemeFtps)
{
request.RequestUri = new UriBuilder(originalUri) { Scheme = Uri.UriSchemeFtp }.Uri;
}
}

var options = new ConnectionOptions(isSecurity, originalUri);
request.Options.Set(key, options);
}

/// <summary>
/// 移除使用自定义连接
/// </summary>
/// <param name="request"></param>
public static void Remove(HttpRequestMessage request)
{
if (request.Options.Remove(key.Key, out var value) &&
value is ConnectionOptions options)
{
request.RequestUri = options.OriginalUri;
}
}
}
}
21 changes: 19 additions & 2 deletions src/DotNetCampus.HttpClientOverOpenSsl/Interop/OpenSSLNative.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,23 @@ IntPtr MapAndLoad(string libraryName, Assembly assembly, DllImportSearchPath? se
}
}

/// <summary>
/// 获取当前平台是否可加载 OpenSSL 原生库。
/// 通过已注册的 DllImportResolver 尝试加载 libssl-3,不抛出异常。
/// </summary>
/// <remarks>
/// 读取此属性会触发静态构造函数,确保 DllImportResolver 已注册。

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. 这里不是属性,而是方法
  2. 应该不会触发静态构造函数吧,打个断点试试?

/// 解析器会遍历所有搜索路径(BaseDirectory、NuGet runtimes、FallbackLibraryPath),
/// 若找到 DLL 则返回 <see langword="true"/> 并缓存句柄供后续 DllImport 使用。
/// </remarks>
public static bool IsOpenSslAvailable()
{
// 通过已注册的 DllImportResolver 尝试加载 libssl-3,
// NativeLibrary.TryLoad 会调用 MapAndLoad 走完整的搜索逻辑。
// 加载成功则说明 OpenSSL 原生库可用,同时句柄会被缓存供后续使用。
return NativeLibrary.TryLoad(LibSslConst, typeof(OpenSSLNative).Assembly, null, out _);
}

#region Constants

public const int SSL_VERIFY_NONE = 0x00;
Expand Down Expand Up @@ -233,7 +250,7 @@ public static int SSL_set_tlsext_host_name(SafeSslHandle ssl, string name)
var namePtr = Marshal.StringToHGlobalAnsi(name);
try
{
return (int) SSL_ctrl(ssl, SSL_CTRL_SET_TLSEXT_HOSTNAME, TLSEXT_NAMETYPE_host_name, namePtr);
return (int)SSL_ctrl(ssl, SSL_CTRL_SET_TLSEXT_HOSTNAME, TLSEXT_NAMETYPE_host_name, namePtr);
}
finally
{
Expand Down Expand Up @@ -273,7 +290,7 @@ public static string GetErrorString(ulong error)
{
var buffer = new byte[256];
ERR_error_string_n(error, buffer, buffer.Length);
var nullIndex = Array.IndexOf(buffer, (byte) 0);
var nullIndex = Array.IndexOf(buffer, (byte)0);
return nullIndex >= 0 ? System.Text.Encoding.ASCII.GetString(buffer, 0, nullIndex) : System.Text.Encoding.ASCII.GetString(buffer);
}

Expand Down
16 changes: 16 additions & 0 deletions src/DotNetCampus.HttpClientOverOpenSsl/OpenSslAsyncStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,24 @@ namespace DotNetCampus.HttpClientOverOpenSsl;
/// </remarks>
internal sealed class OpenSslAsyncStream : Stream
{
private readonly NetworkStream? _innerStream = null;
private readonly bool _leaveInnerStreamOpen;
private readonly Socket _socket;
private readonly bool _ownsSocket;
private SafeSslContextHandle? _sslContext;
private SafeSslHandle? _ssl;
private bool _isAuthenticated;
private bool _disposed;

public static bool IsSupported { get; } = OperatingSystem.IsWindows() && OpenSSLNative.IsOpenSslAvailable();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个很棒,可以来做快速分支,只是我想着可以直接用计算的方式,就不用后备字段:

Suggested change
public static bool IsSupported { get; } = OperatingSystem.IsWindows() && OpenSSLNative.IsOpenSslAvailable();
public static bool IsSupported => OperatingSystem.IsWindows() && OpenSSLNative.IsOpenSslAvailable();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

目前没有做过NativeLibrary.TryLoad的开销计算,IsSupported做为字段存储已计算过的值可以避开不确定的开销。如果不存储已计算过的值,我更倾向于直接设计成IsSupported()方法


public OpenSslAsyncStream(NetworkStream innerStream, bool leaveInnerStreamOpen)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感觉这个构造函数的设计不妙,是否可以直接就绕过了?不要再走这里的构造函数进来

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在代理环境下,需要stream套娃。此构造也是stream套娃,保持 NetworkStream 的引用和生命周期同步;而传入 Socket 的构造器反正不是很需要

: this(innerStream.Socket, ownsSocket: false)
{
_innerStream = innerStream;
_leaveInnerStreamOpen = leaveInnerStreamOpen;
}

/// <summary>
/// 使用指定的 Socket 创建 <see cref="OpenSslAsyncStream"/> 实例。
/// </summary>
Expand Down Expand Up @@ -482,6 +493,11 @@ protected override void Dispose(bool disposing)
{
_socket.Dispose();
}

if (!_leaveInnerStreamOpen && _innerStream is not null)
{
_innerStream.Dispose();
}
}

base.Dispose(disposing);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ namespace DotNetCampus.HttpClientOverOpenSsl;
/// <summary>
/// OpenSSL 客户端认证配置选项。
/// </summary>
internal sealed class OpenSslClientAuthenticationOptions
public sealed class OpenSslClientAuthenticationOptions

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里是为什么要开放呢?我静态阅读代码没有看全哈

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个是要开放给httphandler做为ssl选项属性

{
/// <summary>
/// 目标主机名,用于 TLS SNI(Server Name Indication)和证书验证。
Expand Down
Loading