You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
777 lines
36 KiB
777 lines
36 KiB
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace AIK.Service.Service
|
|
{
|
|
/// <summary>
|
|
/// 基于 BouncyCastle 纯托管 TLS 实现的 HttpMessageHandler,专门用于 Win7。
|
|
/// 背景:Win7 系统 SChannel 默认未启用 TLS 1.2(且补丁/注册表方案常因精简系统、权限、未重启而不生效),
|
|
/// 而 .NET Framework 的 HttpClient/HttpWebRequest 全部走 SChannel,代码层无法绕过。
|
|
/// BouncyCastle 的 TLS 客户端是纯托管实现(Org.BouncyCastle.Crypto.Tls),不依赖系统 SChannel,
|
|
/// 因此在 Win7 上可直接协商 TLS 1.2 与 key.aikkey.cn 握手。
|
|
/// 本 Handler 内部流程:TCP 连接 → BouncyCastle TLS 1.2 握手(含 SNI)→ HTTP/1.1 请求/响应。
|
|
/// </summary>
|
|
public class BouncyCastleTlsHandler : HttpMessageHandler
|
|
{
|
|
private const int DefaultTimeoutMilliseconds = 30000;
|
|
private const int IdleTimeoutMilliseconds = 60000; // 流式下载:响应体每段读取的空闲超时
|
|
private const int MaxRedirects = 5;
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
var currentRequest = request;
|
|
int redirectCount = 0;
|
|
|
|
while (true)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var response = await ExecuteSingleAsync(currentRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
int status = (int)response.StatusCode;
|
|
bool isRedirect = status == 301 || status == 302 || status == 303 || status == 307 || status == 308;
|
|
if (!isRedirect || redirectCount >= MaxRedirects)
|
|
{
|
|
return response;
|
|
}
|
|
|
|
var location = response.Headers.Location;
|
|
response.Dispose();
|
|
if (location == null)
|
|
{
|
|
return new HttpResponseMessage(response.StatusCode); // 无 Location,直接返回原状态
|
|
}
|
|
|
|
redirectCount++;
|
|
var nextUri = location.IsAbsoluteUri ? location : new Uri(currentRequest.RequestUri, location);
|
|
var method = currentRequest.Method;
|
|
|
|
// 301/302/303 对非 GET/HEAD 请求按 HTTP 规范转成 GET;307/308 保持原方法
|
|
if ((status == 301 || status == 302 || status == 303) && method != HttpMethod.Get && method != HttpMethod.Head)
|
|
{
|
|
method = HttpMethod.Get;
|
|
}
|
|
|
|
var next = new HttpRequestMessage(method, nextUri);
|
|
// 携带 token 等业务头
|
|
foreach (var header in currentRequest.Headers)
|
|
{
|
|
next.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
|
}
|
|
if (method == HttpMethod.Get || method == HttpMethod.Head)
|
|
{
|
|
currentRequest.Content?.Dispose();
|
|
}
|
|
else if (currentRequest.Content != null)
|
|
{
|
|
next.Content = new ByteArrayContent(await currentRequest.Content.ReadAsByteArrayAsync().ConfigureAwait(false));
|
|
foreach (var header in currentRequest.Content.Headers)
|
|
{
|
|
next.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
|
}
|
|
}
|
|
currentRequest = next;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 流式发送:仅读取响应头即返回,响应体通过 StreamContent 逐段读取(每段读取带 60s 空闲超时,无总时长限制),
|
|
/// 专用于大文件下载,解决原实现"30s 总超时 + 全量读内存"导致的大文件必失败问题。
|
|
/// 调用方负责 Dispose 响应(会关闭底层 TLS 连接并发出 close_notify)。
|
|
/// </summary>
|
|
public async Task<HttpResponseMessage> SendStreamingAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
var currentRequest = request;
|
|
int redirectCount = 0;
|
|
|
|
while (true)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var response = await ExecuteStreamingAsync(currentRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
int status = (int)response.StatusCode;
|
|
bool isRedirect = status == 301 || status == 302 || status == 303 || status == 307 || status == 308;
|
|
if (!isRedirect || redirectCount >= MaxRedirects)
|
|
{
|
|
return response;
|
|
}
|
|
|
|
var location = response.Headers.Location;
|
|
response.Dispose(); // 重定向不读取 body,直接关闭连接
|
|
if (location == null)
|
|
{
|
|
return new HttpResponseMessage(response.StatusCode); // 无 Location,直接返回原状态
|
|
}
|
|
|
|
redirectCount++;
|
|
var nextUri = location.IsAbsoluteUri ? location : new Uri(currentRequest.RequestUri, location);
|
|
var method = currentRequest.Method;
|
|
|
|
// 301/302/303 对非 GET/HEAD 请求按 HTTP 规范转成 GET;307/308 保持原方法
|
|
if ((status == 301 || status == 302 || status == 303) && method != HttpMethod.Get && method != HttpMethod.Head)
|
|
{
|
|
method = HttpMethod.Get;
|
|
}
|
|
|
|
var next = new HttpRequestMessage(method, nextUri);
|
|
// 携带 token 等业务头
|
|
foreach (var header in currentRequest.Headers)
|
|
{
|
|
next.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
|
}
|
|
if (method == HttpMethod.Get || method == HttpMethod.Head)
|
|
{
|
|
currentRequest.Content?.Dispose();
|
|
}
|
|
else if (currentRequest.Content != null)
|
|
{
|
|
next.Content = new ByteArrayContent(await currentRequest.Content.ReadAsByteArrayAsync().ConfigureAwait(false));
|
|
foreach (var header in currentRequest.Content.Headers)
|
|
{
|
|
next.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
|
}
|
|
}
|
|
currentRequest = next;
|
|
}
|
|
}
|
|
|
|
/// <summary>执行单次 HTTP 请求(不处理重定向)。</summary>
|
|
private async Task<HttpResponseMessage> ExecuteSingleAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
var uri = request.RequestUri;
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeoutCts.CancelAfter(DefaultTimeoutMilliseconds);
|
|
var ct = timeoutCts.Token;
|
|
|
|
// ---- 1. TCP 连接 ----
|
|
using var tcp = new TcpClient();
|
|
try
|
|
{
|
|
var connectTask = tcp.ConnectAsync(uri.Host, uri.Port);
|
|
await Task.WhenAny(connectTask, Task.Delay(Timeout.Infinite, ct)).ConfigureAwait(false);
|
|
ct.ThrowIfCancellationRequested();
|
|
await connectTask.ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (!(ex is OperationCanceledException))
|
|
{
|
|
throw new HttpRequestException($"TCP 连接失败: {uri.Host}:{uri.Port} — {ex.Message}", ex);
|
|
}
|
|
|
|
// ---- 2. BouncyCastle TLS 1.2 握手(复用 BouncyCastleTlsHelper,含 SNI 与证书链校验) ----
|
|
Stream tlsStream;
|
|
try
|
|
{
|
|
tlsStream = await Task.Run(() => BouncyCastleTlsHelper.ConnectTlsStream(tcp.GetStream(), uri.Host), ct).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (!(ex is OperationCanceledException))
|
|
{
|
|
throw new HttpRequestException($"TLS 1.2 握手失败(BouncyCastle): {uri.Host}:{uri.Port} — {ex.Message}", ex);
|
|
}
|
|
|
|
// BufferedStream.Dispose 会连带释放内层 TLS 流(close_notify),因此无需单独 using tlsStream
|
|
using var buffered = new BufferedStream(tlsStream);
|
|
try
|
|
{
|
|
// ---- 3. 发送 HTTP 请求 ----
|
|
byte[] bodyBytes = null;
|
|
if (request.Content != null)
|
|
{
|
|
bodyBytes = await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
var sb = new StringBuilder();
|
|
var pathQuery = uri.PathAndQuery;
|
|
if (string.IsNullOrEmpty(pathQuery)) pathQuery = "/";
|
|
sb.Append(request.Method.Method).Append(' ').Append(pathQuery).Append(" HTTP/1.1\r\n");
|
|
sb.Append("Host: ").Append(uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}").Append("\r\n");
|
|
sb.Append("Connection: close\r\n");
|
|
|
|
// 业务请求头(User-Agent 多值用空格连接,其余用逗号连接)
|
|
foreach (var header in request.Headers)
|
|
{
|
|
bool isUserAgent = string.Equals(header.Key, "User-Agent", StringComparison.OrdinalIgnoreCase);
|
|
sb.Append(header.Key).Append(": ")
|
|
.Append(string.Join(isUserAgent ? " " : ",", header.Value)).Append("\r\n");
|
|
}
|
|
// Content 头(Content-Type / Content-Length 等)
|
|
if (request.Content != null)
|
|
{
|
|
foreach (var header in request.Content.Headers)
|
|
{
|
|
sb.Append(header.Key).Append(": ")
|
|
.Append(string.Join(",", header.Value)).Append("\r\n");
|
|
}
|
|
}
|
|
sb.Append("\r\n");
|
|
|
|
var headBytes = Encoding.ASCII.GetBytes(sb.ToString());
|
|
await buffered.WriteAsync(headBytes, 0, headBytes.Length, ct).ConfigureAwait(false);
|
|
if (bodyBytes != null && bodyBytes.Length > 0)
|
|
{
|
|
await buffered.WriteAsync(bodyBytes, 0, bodyBytes.Length, ct).ConfigureAwait(false);
|
|
}
|
|
await buffered.FlushAsync(ct).ConfigureAwait(false);
|
|
|
|
// ---- 4. 读取响应头 ----
|
|
string statusLine = await ReadLineAsync(buffered, ct).ConfigureAwait(false);
|
|
if (string.IsNullOrEmpty(statusLine))
|
|
{
|
|
throw new HttpRequestException("服务器返回空响应(连接被关闭)");
|
|
}
|
|
|
|
var statusParts = statusLine.Split(' ');
|
|
if (statusParts.Length < 2 || !statusParts[0].StartsWith("HTTP/", StringComparison.Ordinal))
|
|
{
|
|
throw new HttpRequestException($"无法解析 HTTP 状态行: {statusLine}");
|
|
}
|
|
int statusCode = int.Parse(statusParts[1], CultureInfo.InvariantCulture);
|
|
string reasonPhrase = statusParts.Length > 2 ? string.Join(" ", statusParts, 2, statusParts.Length - 2) : string.Empty;
|
|
|
|
var responseHeaders = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
|
string line;
|
|
while (!string.IsNullOrEmpty(line = await ReadLineAsync(buffered, ct).ConfigureAwait(false)))
|
|
{
|
|
int colon = line.IndexOf(':');
|
|
if (colon <= 0) continue;
|
|
var name = line.Substring(0, colon).Trim();
|
|
var value = line.Substring(colon + 1).Trim();
|
|
if (!responseHeaders.TryGetValue(name, out var list))
|
|
{
|
|
list = new List<string>();
|
|
responseHeaders[name] = list;
|
|
}
|
|
list.Add(value);
|
|
}
|
|
|
|
// ---- 5. 读取响应体 ----
|
|
byte[] body = await ReadBodyAsync(buffered, responseHeaders, ct).ConfigureAwait(false);
|
|
|
|
// ---- 6. 解压(兼容原 HttpClientHandler.AutomaticDecompression 行为) ----
|
|
if (responseHeaders.TryGetValue("Content-Encoding", out var encodings) && encodings.Count > 0)
|
|
{
|
|
string encoding = encodings[0].ToLowerInvariant();
|
|
if (encoding == "gzip" && body.Length > 0)
|
|
{
|
|
using var gz = new GZipStream(new MemoryStream(body), CompressionMode.Decompress);
|
|
body = ReadAllBytes(gz);
|
|
responseHeaders.Remove("Content-Encoding");
|
|
}
|
|
else if (encoding == "deflate" && body.Length > 0)
|
|
{
|
|
using var def = new DeflateStream(new MemoryStream(body), CompressionMode.Decompress);
|
|
body = ReadAllBytes(def);
|
|
responseHeaders.Remove("Content-Encoding");
|
|
}
|
|
}
|
|
|
|
// ---- 7. 包装为 HttpResponseMessage ----
|
|
var msg = new HttpResponseMessage((HttpStatusCode)statusCode)
|
|
{
|
|
ReasonPhrase = string.IsNullOrEmpty(reasonPhrase) ? null : reasonPhrase,
|
|
Content = new ByteArrayContent(body)
|
|
};
|
|
foreach (var kv in responseHeaders)
|
|
{
|
|
if (IsContentHeader(kv.Key))
|
|
{
|
|
msg.Content.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
|
|
}
|
|
else
|
|
{
|
|
msg.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
|
|
}
|
|
}
|
|
return msg;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (OperationCanceledException ex)
|
|
{
|
|
throw new HttpRequestException($"请求超时({DefaultTimeoutMilliseconds}ms): {uri.Host}", ex);
|
|
}
|
|
catch (Exception ex) when (!(ex is HttpRequestException))
|
|
{
|
|
throw new HttpRequestException($"HTTP 请求失败: {uri.Host} — {ex.Message}", ex);
|
|
}
|
|
// 注:tcp/tlsStream/buffered 均为 using 声明,按反向顺序释放(buffered → tlsStream → tcp),
|
|
// 确保 BouncyCastle 在 socket 关闭前发出 close_notify,避免 internal_error。
|
|
}
|
|
|
|
/// <summary>执行单次流式 HTTP 请求(不处理重定向)。响应体不预读,交给调用方通过 StreamContent 逐段读取。</summary>
|
|
private async Task<HttpResponseMessage> ExecuteStreamingAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
var uri = request.RequestUri;
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeoutCts.CancelAfter(DefaultTimeoutMilliseconds); // 仅覆盖:连接/握手/发送/读响应头
|
|
var ct = timeoutCts.Token;
|
|
|
|
// ---- 1. TCP 连接 ----
|
|
var tcp = new TcpClient();
|
|
try
|
|
{
|
|
var connectTask = tcp.ConnectAsync(uri.Host, uri.Port);
|
|
await Task.WhenAny(connectTask, Task.Delay(Timeout.Infinite, ct)).ConfigureAwait(false);
|
|
ct.ThrowIfCancellationRequested();
|
|
await connectTask.ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (!(ex is OperationCanceledException))
|
|
{
|
|
tcp.Dispose();
|
|
throw new HttpRequestException($"TCP 连接失败: {uri.Host}:{uri.Port} — {ex.Message}", ex);
|
|
}
|
|
|
|
// ---- 2. BouncyCastle TLS 1.2 握手(复用 BouncyCastleTlsHelper,含 SNI 与证书链校验) ----
|
|
Stream tlsStream;
|
|
try
|
|
{
|
|
tlsStream = await Task.Run(() => BouncyCastleTlsHelper.ConnectTlsStream(tcp.GetStream(), uri.Host), ct).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (!(ex is OperationCanceledException))
|
|
{
|
|
tcp.Dispose();
|
|
throw new HttpRequestException($"TLS 1.2 握手失败(BouncyCastle): {uri.Host}:{uri.Port} — {ex.Message}", ex);
|
|
}
|
|
|
|
var buffered = new BufferedStream(tlsStream);
|
|
try
|
|
{
|
|
// ---- 3. 发送 HTTP 请求 ----
|
|
byte[] bodyBytes = null;
|
|
if (request.Content != null)
|
|
{
|
|
bodyBytes = await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
var sb = new StringBuilder();
|
|
var pathQuery = uri.PathAndQuery;
|
|
if (string.IsNullOrEmpty(pathQuery)) pathQuery = "/";
|
|
sb.Append(request.Method.Method).Append(' ').Append(pathQuery).Append(" HTTP/1.1\r\n");
|
|
sb.Append("Host: ").Append(uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}").Append("\r\n");
|
|
sb.Append("Connection: close\r\n");
|
|
|
|
// 业务请求头(User-Agent 多值用空格连接,其余用逗号连接)
|
|
foreach (var header in request.Headers)
|
|
{
|
|
bool isUserAgent = string.Equals(header.Key, "User-Agent", StringComparison.OrdinalIgnoreCase);
|
|
sb.Append(header.Key).Append(": ")
|
|
.Append(string.Join(isUserAgent ? " " : ",", header.Value)).Append("\r\n");
|
|
}
|
|
// Content 头(Content-Type / Content-Length 等)
|
|
if (request.Content != null)
|
|
{
|
|
foreach (var header in request.Content.Headers)
|
|
{
|
|
sb.Append(header.Key).Append(": ")
|
|
.Append(string.Join(",", header.Value)).Append("\r\n");
|
|
}
|
|
}
|
|
sb.Append("\r\n");
|
|
|
|
var headBytes = Encoding.ASCII.GetBytes(sb.ToString());
|
|
await buffered.WriteAsync(headBytes, 0, headBytes.Length, ct).ConfigureAwait(false);
|
|
if (bodyBytes != null && bodyBytes.Length > 0)
|
|
{
|
|
await buffered.WriteAsync(bodyBytes, 0, bodyBytes.Length, ct).ConfigureAwait(false);
|
|
}
|
|
await buffered.FlushAsync(ct).ConfigureAwait(false);
|
|
|
|
// ---- 4. 读取响应头 ----
|
|
string statusLine = await ReadLineAsync(buffered, ct).ConfigureAwait(false);
|
|
if (string.IsNullOrEmpty(statusLine))
|
|
{
|
|
throw new HttpRequestException("服务器返回空响应(连接被关闭)");
|
|
}
|
|
|
|
var statusParts = statusLine.Split(' ');
|
|
if (statusParts.Length < 2 || !statusParts[0].StartsWith("HTTP/", StringComparison.Ordinal))
|
|
{
|
|
throw new HttpRequestException($"无法解析 HTTP 状态行: {statusLine}");
|
|
}
|
|
int statusCode = int.Parse(statusParts[1], CultureInfo.InvariantCulture);
|
|
string reasonPhrase = statusParts.Length > 2 ? string.Join(" ", statusParts, 2, statusParts.Length - 2) : string.Empty;
|
|
|
|
var responseHeaders = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
|
string line;
|
|
while (!string.IsNullOrEmpty(line = await ReadLineAsync(buffered, ct).ConfigureAwait(false)))
|
|
{
|
|
int colon = line.IndexOf(':');
|
|
if (colon <= 0) continue;
|
|
var name = line.Substring(0, colon).Trim();
|
|
var value = line.Substring(colon + 1).Trim();
|
|
if (!responseHeaders.TryGetValue(name, out var list))
|
|
{
|
|
list = new List<string>();
|
|
responseHeaders[name] = list;
|
|
}
|
|
list.Add(value);
|
|
}
|
|
|
|
// ---- 5. 构造流式响应体(chunked 解码 + gzip/deflate 解压 + 每段空闲超时) ----
|
|
var bodyStream = CreateBodyStream(buffered, responseHeaders);
|
|
|
|
// ---- 6. 包装为 HttpResponseMessage ----
|
|
var msg = new HttpResponseMessage((HttpStatusCode)statusCode)
|
|
{
|
|
ReasonPhrase = string.IsNullOrEmpty(reasonPhrase) ? null : reasonPhrase,
|
|
Content = new StreamContent(bodyStream)
|
|
};
|
|
foreach (var kv in responseHeaders)
|
|
{
|
|
if (IsContentHeader(kv.Key))
|
|
{
|
|
msg.Content.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
|
|
}
|
|
else
|
|
{
|
|
msg.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
|
|
}
|
|
}
|
|
// 连接所有权移交给 bodyStream,Dispose 响应时按序关闭(buffered → tlsStream → tcp)
|
|
((TlsBodyStream)bodyStream).AttachConnection(tcp, buffered);
|
|
return msg;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
buffered.Dispose();
|
|
tcp.Dispose();
|
|
throw;
|
|
}
|
|
catch (OperationCanceledException ex)
|
|
{
|
|
buffered.Dispose();
|
|
tcp.Dispose();
|
|
throw new HttpRequestException($"请求超时({DefaultTimeoutMilliseconds}ms): {uri.Host}", ex);
|
|
}
|
|
catch (Exception ex) when (!(ex is HttpRequestException))
|
|
{
|
|
buffered.Dispose();
|
|
tcp.Dispose();
|
|
throw new HttpRequestException($"HTTP 请求失败: {uri.Host} — {ex.Message}", ex);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 按响应头构造流式读取链:Transfer-Encoding: chunked → ChunkedDecodingStream;Content-Encoding → gzip/deflate 解压流。
|
|
/// 连接(tcp/buffered)随后通过 TlsBodyStream.AttachConnection 移交。
|
|
/// </summary>
|
|
private static TlsBodyStream CreateBodyStream(Stream buffered, Dictionary<string, List<string>> headers)
|
|
{
|
|
Stream stream = buffered;
|
|
|
|
if (headers.TryGetValue("Transfer-Encoding", out var tes))
|
|
{
|
|
string te = string.Join(",", tes).ToLowerInvariant();
|
|
if (te.Contains("chunked"))
|
|
{
|
|
stream = new ChunkedDecodingStream(stream);
|
|
}
|
|
}
|
|
|
|
if (headers.TryGetValue("Content-Encoding", out var encodings) && encodings.Count > 0)
|
|
{
|
|
string encoding = encodings[0].ToLowerInvariant();
|
|
if (encoding == "gzip")
|
|
{
|
|
stream = new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true);
|
|
}
|
|
else if (encoding == "deflate")
|
|
{
|
|
stream = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true);
|
|
}
|
|
// 解压后字节数不再等于 Content-Length,移除该头避免下游按 Content-Length 校验/判进度时误判
|
|
headers.Remove("Content-Encoding");
|
|
headers.Remove("Content-Length");
|
|
}
|
|
|
|
return new TlsBodyStream(stream, headers);
|
|
}
|
|
|
|
/// <summary>按 Transfer-Encoding / Content-Length / EOF 读取响应体。</summary>
|
|
private static async Task<byte[]> ReadBodyAsync(Stream stream, Dictionary<string, List<string>> headers, CancellationToken ct)
|
|
{
|
|
if (headers.TryGetValue("Transfer-Encoding", out var tes))
|
|
{
|
|
string te = string.Join(",", tes).ToLowerInvariant();
|
|
if (te.Contains("chunked"))
|
|
{
|
|
return await ReadChunkedAsync(stream, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
if (headers.TryGetValue("Content-Length", out var cl) && cl.Count > 0
|
|
&& long.TryParse(cl[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long length))
|
|
{
|
|
return await ReadExactlyAsync(stream, length, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
// 无长度信息(Connection: close 场景)→ 读到 EOF
|
|
return await ReadAllAsync(stream, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task<byte[]> ReadChunkedAsync(Stream stream, CancellationToken ct)
|
|
{
|
|
using var ms = new MemoryStream();
|
|
while (true)
|
|
{
|
|
string sizeLine = await ReadLineAsync(stream, ct).ConfigureAwait(false);
|
|
if (string.IsNullOrEmpty(sizeLine)) break;
|
|
int semi = sizeLine.IndexOf(';');
|
|
if (semi >= 0) sizeLine = sizeLine.Substring(0, semi);
|
|
int size = int.Parse(sizeLine.Trim(), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
|
if (size == 0)
|
|
{
|
|
// 读取 trailer 直到空行
|
|
while (!string.IsNullOrEmpty(await ReadLineAsync(stream, ct).ConfigureAwait(false))) { }
|
|
break;
|
|
}
|
|
var chunk = await ReadExactlyAsync(stream, size, ct).ConfigureAwait(false);
|
|
ms.Write(chunk, 0, chunk.Length);
|
|
await ReadExactlyAsync(stream, 2, ct).ConfigureAwait(false); // \r\n
|
|
}
|
|
return ms.ToArray();
|
|
}
|
|
|
|
private static async Task<byte[]> ReadExactlyAsync(Stream stream, long count, CancellationToken ct)
|
|
{
|
|
if (count > int.MaxValue) throw new HttpRequestException("响应体过大");
|
|
var buffer = new byte[(int)count];
|
|
int offset = 0;
|
|
while (offset < buffer.Length)
|
|
{
|
|
int read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, ct).ConfigureAwait(false);
|
|
if (read == 0) throw new HttpRequestException($"响应体提前结束(期望 {count} 字节,实际 {offset})");
|
|
offset += read;
|
|
}
|
|
return buffer;
|
|
}
|
|
|
|
private static async Task<byte[]> ReadAllAsync(Stream stream, CancellationToken ct)
|
|
{
|
|
using var ms = new MemoryStream();
|
|
var buffer = new byte[81920];
|
|
while (true)
|
|
{
|
|
int read = await stream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false);
|
|
if (read == 0) break;
|
|
ms.Write(buffer, 0, read);
|
|
}
|
|
return ms.ToArray();
|
|
}
|
|
|
|
/// <summary>逐行读取(\r\n 结尾,不含结尾符)。</summary>
|
|
private static async Task<string> ReadLineAsync(Stream stream, CancellationToken ct)
|
|
{
|
|
var sb = new StringBuilder();
|
|
var buffer = new byte[1];
|
|
int prev = -1;
|
|
while (true)
|
|
{
|
|
int read = await stream.ReadAsync(buffer, 0, 1, ct).ConfigureAwait(false);
|
|
if (read == 0)
|
|
{
|
|
if (sb.Length == 0) return null;
|
|
break; // EOF,返回已收集内容
|
|
}
|
|
int b = buffer[0];
|
|
if (prev == '\r' && b == '\n')
|
|
{
|
|
sb.Remove(sb.Length - 1, 1); // 去掉 \r
|
|
break;
|
|
}
|
|
prev = b;
|
|
sb.Append((char)b);
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static byte[] ReadAllBytes(Stream stream)
|
|
{
|
|
using var ms = new MemoryStream();
|
|
stream.CopyTo(ms);
|
|
return ms.ToArray();
|
|
}
|
|
|
|
private static readonly HashSet<string> ContentHeaders = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"Content-Type", "Content-Length", "Content-Encoding", "Content-Language",
|
|
"Content-Location", "Content-Range", "Content-Disposition", "Content-MD5",
|
|
"Expires", "Last-Modified", "Allow", "Content-Transfer-Encoding", "Content-Security-Policy"
|
|
};
|
|
|
|
private static bool IsContentHeader(string name) => ContentHeaders.Contains(name);
|
|
|
|
/// <summary>
|
|
/// 流式响应体读取流:每段 ReadAsync 带空闲超时(无总时长限制),Dispose 时按序关闭
|
|
/// buffered → tlsStream(close_notify)→ tcp,连接由 AttachConnection 在响应成功后移交。
|
|
/// </summary>
|
|
private sealed class TlsBodyStream : Stream
|
|
{
|
|
private readonly Stream _inner; // 解码链后的流(可能为 ChunkedDecodingStream / GZipStream 包装)
|
|
private readonly Dictionary<string, List<string>> _headers;
|
|
private TcpClient _tcp;
|
|
private Stream _buffered;
|
|
private bool _disposed;
|
|
|
|
public TlsBodyStream(Stream inner, Dictionary<string, List<string>> headers)
|
|
{
|
|
_inner = inner;
|
|
_headers = headers;
|
|
}
|
|
|
|
public void AttachConnection(TcpClient tcp, Stream buffered)
|
|
{
|
|
_tcp = tcp;
|
|
_buffered = buffered;
|
|
}
|
|
|
|
public override bool CanRead => true;
|
|
public override bool CanSeek => false;
|
|
public override bool CanWrite => false;
|
|
public override long Length => throw new NotSupportedException();
|
|
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
|
public override void Flush() { }
|
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
|
public override void SetLength(long value) => throw new NotSupportedException();
|
|
|
|
public override int Read(byte[] buffer, int offset, int count)
|
|
=> ReadAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult();
|
|
|
|
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
|
{
|
|
using var idleCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
idleCts.CancelAfter(IdleTimeoutMilliseconds);
|
|
try
|
|
{
|
|
return await _inner.ReadAsync(buffer, offset, count, idleCts.Token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw new HttpRequestException($"读取响应数据超时(空闲 {IdleTimeoutMilliseconds}ms 无数据)");
|
|
}
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
if (disposing)
|
|
{
|
|
try { _inner.Dispose(); } catch { } // 先关闭解码链(GZipStream 等 leaveOpen 不关底层)
|
|
try { _buffered?.Dispose(); } catch { } // buffered → tlsStream close_notify
|
|
try { _tcp?.Dispose(); } catch { }
|
|
}
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
|
|
/// <summary>HTTP chunked 分块解码流:隐藏分块帧,向调用方暴露连续的解码后数据。</summary>
|
|
private sealed class ChunkedDecodingStream : Stream
|
|
{
|
|
private readonly Stream _inner;
|
|
private long _chunkRemaining;
|
|
private bool _finished;
|
|
|
|
public ChunkedDecodingStream(Stream inner) => _inner = inner;
|
|
|
|
public override bool CanRead => true;
|
|
public override bool CanSeek => false;
|
|
public override bool CanWrite => false;
|
|
public override long Length => throw new NotSupportedException();
|
|
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
|
public override void Flush() { }
|
|
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
|
public override void SetLength(long value) => throw new NotSupportedException();
|
|
|
|
public override int Read(byte[] buffer, int offset, int count)
|
|
=> ReadAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult();
|
|
|
|
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
|
{
|
|
if (_finished) return 0;
|
|
|
|
while (_chunkRemaining == 0)
|
|
{
|
|
// 读取下一个 chunk 大小行(hex)
|
|
string sizeLine = await ReadChunkLineAsync(cancellationToken).ConfigureAwait(false);
|
|
if (string.IsNullOrEmpty(sizeLine))
|
|
{
|
|
// EOF 提前结束:正常终止必须读到 0 长度的终止块,这里说明连接被中途掐断
|
|
throw new HttpRequestException("chunked 响应体提前结束(未读到终止块)");
|
|
}
|
|
int semi = sizeLine.IndexOf(';');
|
|
if (semi >= 0) sizeLine = sizeLine.Substring(0, semi);
|
|
_chunkRemaining = long.Parse(sizeLine.Trim(), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
|
if (_chunkRemaining == 0)
|
|
{
|
|
// 终止块:读取 trailer 直到空行
|
|
while (!string.IsNullOrEmpty(await ReadChunkLineAsync(cancellationToken).ConfigureAwait(false))) { }
|
|
_finished = true;
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
int toRead = (int)Math.Min(count, _chunkRemaining);
|
|
int read = await _inner.ReadAsync(buffer, offset, toRead, cancellationToken).ConfigureAwait(false);
|
|
if (read == 0) throw new HttpRequestException("chunked 响应体提前结束");
|
|
_chunkRemaining -= read;
|
|
if (_chunkRemaining == 0)
|
|
{
|
|
// 吃掉 chunk 后的 \r\n
|
|
var crlf = new byte[2];
|
|
int got = 0;
|
|
while (got < 2)
|
|
{
|
|
int r = await _inner.ReadAsync(crlf, got, 2 - got, cancellationToken).ConfigureAwait(false);
|
|
if (r == 0) break;
|
|
got += r;
|
|
}
|
|
}
|
|
return read;
|
|
}
|
|
|
|
/// <summary>逐字节读一行(\r\n 结尾,不含结尾符),复用于 chunk 大小行与 trailer。</summary>
|
|
private async Task<string> ReadChunkLineAsync(CancellationToken cancellationToken)
|
|
{
|
|
var sb = new StringBuilder();
|
|
var buffer = new byte[1];
|
|
int prev = -1;
|
|
while (true)
|
|
{
|
|
int read = await _inner.ReadAsync(buffer, 0, 1, cancellationToken).ConfigureAwait(false);
|
|
if (read == 0)
|
|
{
|
|
if (sb.Length == 0) return null;
|
|
break;
|
|
}
|
|
int b = buffer[0];
|
|
if (prev == '\r' && b == '\n')
|
|
{
|
|
sb.Remove(sb.Length - 1, 1); // 去掉 \r
|
|
break;
|
|
}
|
|
prev = b;
|
|
sb.Append((char)b);
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing) _inner.Dispose();
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|