初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.Caching;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Ae.Dns.Client;
|
||||
using Ae.Dns.Protocol;
|
||||
using Ae.Dns.Protocol.Enums;
|
||||
using Ae.Dns.Protocol.Records;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
using PCL.Core.Logging;
|
||||
|
||||
namespace PCL.Core.IO.Net.Dns;
|
||||
|
||||
public class DnsQuery : IDisposable
|
||||
{
|
||||
private const string ModuleName = "DoH query";
|
||||
public static DnsQuery Instance { get; } = new();
|
||||
|
||||
private readonly DnsCachingClient _resolver;
|
||||
private readonly HttpClient[] _httpClients;
|
||||
|
||||
private DnsQuery()
|
||||
{
|
||||
var proxyHandler = new HttpClientHandler()
|
||||
{
|
||||
Proxy = HttpProxyManager.Instance
|
||||
};
|
||||
// 使用Ae.Dns创建DoH客户端,支持多个DoH服务器
|
||||
_httpClients =
|
||||
[
|
||||
new HttpClient(proxyHandler)
|
||||
{
|
||||
BaseAddress = new Uri("https://doh.pub/")
|
||||
},
|
||||
new HttpClient(proxyHandler)
|
||||
{
|
||||
BaseAddress = new Uri("https://doh.pysio.online/")
|
||||
},
|
||||
new HttpClient(proxyHandler)
|
||||
{
|
||||
BaseAddress = new Uri("https://cloudflare-dns.com/")
|
||||
}
|
||||
];
|
||||
_resolver = new DnsCachingClient(
|
||||
new WeightedDnsRacerClient(2, _httpClients.Select(static x => new DnsHttpClient(x)).ToArray<IDnsClient>()),
|
||||
new MemoryCache("DoH Query Cache"));
|
||||
}
|
||||
|
||||
public async Task<DnsMessage?> QueryAsync(string host, DnsQueryType qType, CancellationToken cts = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _resolver.Query(DnsQueryFactory.CreateQuery(host, qType), cts);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ModuleName, $"Failed to resolve DNS for {host}: {ex.Message}, use system default DNS");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IPAddress[]?> QueryForIpAsync(string host, CancellationToken cts = default)
|
||||
{
|
||||
var queryResponse = await Task.WhenAll(
|
||||
[
|
||||
QueryAsync(host, DnsQueryType.A, cts),
|
||||
QueryAsync(host, DnsQueryType.AAAA, cts)
|
||||
]
|
||||
);
|
||||
|
||||
if (queryResponse.All(static x => x is null))
|
||||
{
|
||||
LogWrapper.Warn(ModuleName, $"Failed to query IP for host {host} using DoH, use system default DNS");
|
||||
return await System.Net.Dns.GetHostAddressesAsync(host, cts);
|
||||
}
|
||||
|
||||
return queryResponse.Where(static x => x is not null)
|
||||
.SelectMany(static x => x!.Answers)
|
||||
.Where(static x => x.Type is DnsQueryType.A or DnsQueryType.AAAA)
|
||||
.Select(static x => x.Resource as DnsIpAddressResource)
|
||||
.Where(static x => x is not null)
|
||||
.Select(static x => x!.IPAddress)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
foreach (var client in _httpClients)
|
||||
{
|
||||
client.Dispose();
|
||||
}
|
||||
_resolver.Dispose(); // 好像这个包的 Dispose 并没有做什么 lol
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Ae.Dns.Protocol.Records;
|
||||
|
||||
namespace PCL.Core.IO.Net.Dns;
|
||||
|
||||
public class DnsSrvResource : IDnsResource
|
||||
{
|
||||
public string Target { get; set; } = "";
|
||||
public int Weight { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public int Port { get; set; }
|
||||
|
||||
public void WriteBytes(Memory<byte> bytes, ref int offset)
|
||||
{
|
||||
// 6 Bytes for priority, weight, port and 2 bytes for length
|
||||
var buf = bytes.Span[offset..];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf[offset..(offset + 2)], (ushort)Priority);
|
||||
offset += 2;
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf[offset..(offset + 2)], (ushort)Weight);
|
||||
offset += 2;
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf[offset..(offset + 2)], (ushort)Port);
|
||||
offset += 2;
|
||||
// target string
|
||||
foreach (var seg in Target.Split('.'))
|
||||
{
|
||||
var segBuf = Encoding.UTF8.GetBytes(seg);
|
||||
var segLength = (byte)segBuf.Length;
|
||||
buf[offset] = segLength;
|
||||
offset++;
|
||||
segBuf.CopyTo(buf[offset..]);
|
||||
offset += segBuf.Length;
|
||||
}
|
||||
}
|
||||
|
||||
public void ReadBytes(ReadOnlyMemory<byte> bytes, ref int offset, int length)
|
||||
{
|
||||
var buf = bytes.Slice(offset, length).Span;
|
||||
offset += length;
|
||||
var priorityBuf = buf[..2];
|
||||
Priority = BinaryPrimitives.ReadUInt16BigEndian(priorityBuf);
|
||||
var weightBuf = buf[2..4];
|
||||
Weight = BinaryPrimitives.ReadUInt16BigEndian(weightBuf);
|
||||
var portBuf = buf[4..6];
|
||||
Port = BinaryPrimitives.ReadUInt16BigEndian(portBuf);
|
||||
// left target buf. 1 Byte for length, then the string. Until we got end of string.
|
||||
var segments = new List<string>();
|
||||
var i = 6;
|
||||
while (i < length)
|
||||
{
|
||||
var segLength = buf[i];
|
||||
if (segLength == 0) break;
|
||||
i++;
|
||||
if (i + segLength > length)
|
||||
{
|
||||
throw new ArgumentException("Invalid DNS SRV resource record: segment length exceeds buffer size");
|
||||
}
|
||||
|
||||
segments.Add(Encoding.UTF8.GetString(buf.Slice(i, segLength)));
|
||||
i += segLength;
|
||||
}
|
||||
|
||||
Target = string.Join('.', segments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Ae.Dns.Protocol;
|
||||
using PCL.Core.Utils.Exts;
|
||||
|
||||
namespace PCL.Core.IO.Net.Dns;
|
||||
|
||||
public sealed class WeightedDnsRacerClient : IDnsClient
|
||||
{
|
||||
private readonly (IDnsClient Client, byte Weight)[] _clients;
|
||||
private readonly int _concurrentCount;
|
||||
private bool _updateWeigh = true;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public WeightedDnsRacerClient(int concurrentCount, params IDnsClient[] clients)
|
||||
{
|
||||
if (clients is null || clients.Length < concurrentCount)
|
||||
throw new ArgumentException("Not enough clients.");
|
||||
|
||||
_concurrentCount = concurrentCount;
|
||||
_clients = clients.Select(static c => (Client: c, Weight: (byte)0)).ToArray();
|
||||
}
|
||||
|
||||
public async Task<DnsMessage> Query(DnsMessage query, CancellationToken ct = default)
|
||||
{
|
||||
(IDnsClient Client, byte Weight)[] candidates;
|
||||
lock (_lock)
|
||||
{
|
||||
candidates = _clients
|
||||
.OrderByDescending(static x => x.Weight)
|
||||
.Take(_concurrentCount)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
var tasks = new Task<DnsMessage>[candidates.Length];
|
||||
|
||||
for (var i = 0; i < candidates.Length; i++)
|
||||
{
|
||||
var task = candidates[i].Client.Query(query, cts.Token);
|
||||
task.Forget();
|
||||
tasks[i] = task;
|
||||
}
|
||||
|
||||
var winnerTask = await tasks.WhenAnySuccessAsync().ConfigureAwait(false);
|
||||
if (winnerTask is null) throw new InvalidOperationException("All queries failed.");
|
||||
|
||||
var winner = candidates[Array.IndexOf(tasks, winnerTask)].Client;
|
||||
|
||||
if (_updateWeigh)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
for (var i = 0; i < _clients.Length; i++)
|
||||
{
|
||||
if (_clients[i].Client == winner)
|
||||
{
|
||||
if (_clients[i].Weight < 255)
|
||||
_clients[i] = (_clients[i].Client, (byte)(_clients[i].Weight + 1));
|
||||
else
|
||||
_updateWeigh = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
return await winnerTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var (client, _) in _clients)
|
||||
client?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using PCL.Core.IO.Storage.Cache;
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP 缓存处理器
|
||||
/// </summary>
|
||||
public class HttpCacheHandler : DelegatingHandler
|
||||
{
|
||||
private ICacheService _cacheService;
|
||||
public HttpCacheHandler(HttpMessageHandler invoker, ICacheService cacheService)
|
||||
{
|
||||
InnerHandler = invoker;
|
||||
_cacheService = cacheService;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
var uri = request.RequestUri?.ToString();
|
||||
if (string.IsNullOrEmpty(uri))
|
||||
{
|
||||
return await base.SendAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// seek cache
|
||||
var cacheKey = CacheKeys.ApiResponse("http", uri);
|
||||
var cached = await _cacheService.GetAsync<byte[]>(cacheKey, ct).ConfigureAwait(false);
|
||||
if (cached.Found)
|
||||
{
|
||||
var cachedResponse = _DeserializeResponse(cached.Value!);
|
||||
cachedResponse.Headers.Add("X-Cache-Hit", "HIT");
|
||||
cachedResponse.RequestMessage = request;
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// seek metadata
|
||||
var metaKey = CacheKeys.ApiResponseMeta("http", uri);
|
||||
var metaCached = await _cacheService.GetAsync<HttpCacheMetadata>(metaKey, ct).ConfigureAwait(false);
|
||||
if (metaCached.Found)
|
||||
{
|
||||
if (metaCached.Value!.ETag is not null)
|
||||
{
|
||||
request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(metaCached.Value!.ETag));
|
||||
}
|
||||
|
||||
if (metaCached.Value!.LastModified is not null)
|
||||
{
|
||||
request.Headers.IfModifiedSince = DateTimeOffset.Parse(metaCached.Value!.LastModified);
|
||||
}
|
||||
}
|
||||
|
||||
// send request
|
||||
var response = await base.SendAsync(request, ct).ConfigureAwait(false);
|
||||
if (response.Headers.CacheControl?.NoStore ?? false)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
// not modified, return cached response if exists
|
||||
if (response.StatusCode is HttpStatusCode.NotModified)
|
||||
{
|
||||
var reCached = await _cacheService.GetAsync<byte[]>(cacheKey, ct).ConfigureAwait(false);
|
||||
if (reCached.Found)
|
||||
{
|
||||
var cachedResponse = _DeserializeResponse(reCached.Value!);
|
||||
cachedResponse.Headers.Add("X-Cache-Hit", "HIT");
|
||||
cachedResponse.RequestMessage = request;
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// cache response
|
||||
var body = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
var ttl = _ComputeTtl(response);
|
||||
|
||||
await _cacheService.SetAsync(cacheKey, body, new CachePolicy
|
||||
{
|
||||
AbsoluteExpiration = ttl,
|
||||
Group = "http",
|
||||
Tags = "http-response"
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
await _cacheService.SetAsync(metaKey, new HttpCacheMetadata
|
||||
{
|
||||
ETag = response.Headers.ETag?.Tag,
|
||||
LastModified = response.Content.Headers.LastModified?.ToString("O"),
|
||||
StatusCode = (int)response.StatusCode
|
||||
}, CachePolicy.NeverExpire, ct).ConfigureAwait(false);
|
||||
|
||||
// not matched, return original response
|
||||
response.Headers.Add("X-Cache-Hit", "MISS");
|
||||
return response;
|
||||
}
|
||||
|
||||
private static TimeSpan? _ComputeTtl(HttpResponseMessage response)
|
||||
{
|
||||
var cc = response.Headers.CacheControl;
|
||||
if (cc?.MaxAge is not null)
|
||||
{
|
||||
return cc.MaxAge;
|
||||
}
|
||||
|
||||
if (cc?.SharedMaxAge is not null)
|
||||
{
|
||||
return cc.SharedMaxAge;
|
||||
}
|
||||
|
||||
return TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage _DeserializeResponse(byte[] data)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(data)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PCL.Core.IO.Net.Http.Cache;
|
||||
|
||||
public record HttpCacheMetadata
|
||||
{
|
||||
public string? ETag { get; init; }
|
||||
public string? LastModified { get; init; }
|
||||
public int StatusCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.IO.Net.Dns;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Exts;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public class HostConnectionHandler
|
||||
{
|
||||
public static HostConnectionHandler Instance { get; } = new();
|
||||
private const string ModuleName = "HostConnectionHandler";
|
||||
|
||||
private readonly DnsQuery _dnsQuery = DnsQuery.Instance;
|
||||
private static readonly TimeSpan _CacheDuration = TimeSpan.FromMinutes(10); // 10 分钟缓存(RFC 建议值)
|
||||
private const int WaitTasks = 2;
|
||||
|
||||
// 缓存结构: (host, port) -> (IPAddress -> LastSuccessUtc)
|
||||
private readonly ConcurrentDictionary<(string host, int port), ConcurrentDictionary<IPAddress, DateTime>>
|
||||
_connectionCache = new();
|
||||
|
||||
public async ValueTask<Stream> GetConnectionAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var host = context.DnsEndPoint.Host;
|
||||
var port = context.DnsEndPoint.Port;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 代理地址或者直连 IP 直接返回结果
|
||||
if (IPAddress.TryParse(host, out var directIp))
|
||||
{
|
||||
return await _ConnectToAddressAsync(directIp, port, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var addresses = await _dnsQuery.QueryForIpAsync(host, cancellationToken).ConfigureAwait(false);
|
||||
if (addresses is null || addresses.Length == 0)
|
||||
{
|
||||
throw new HttpRequestException($"DNS resolution failed for {host}");
|
||||
}
|
||||
|
||||
// 对待连接地址进行排序 (缓存成功+IPv6优先)
|
||||
var sortedAddresses = _SortAddresses(host, port, addresses, now);
|
||||
|
||||
// Happy Eyeballs 连接逻辑,优先 IPv6 稍后
|
||||
var connectionTasks = new List<Task<NetworkStream>>(WaitTasks);
|
||||
var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var connectCancellationToken = cancellationSource.Token;
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < sortedAddresses.Length; i++)
|
||||
{
|
||||
var address = sortedAddresses[i];
|
||||
var delayMs = i * (address.AddressFamily.Equals(AddressFamily.InterNetwork) ? 150 : 80); // 偏向使用 v6
|
||||
|
||||
connectionTasks.Add(_DelayedConnectAsync(address, port, delayMs, connectCancellationToken));
|
||||
}
|
||||
|
||||
// 等待首个成功连接
|
||||
var winner = await connectionTasks
|
||||
.ToArray()
|
||||
.WhenAnySuccessAsync()
|
||||
.ConfigureAwait(false);
|
||||
var stream = await winner.ConfigureAwait(false);
|
||||
|
||||
// 更新缓存 + 记录成功
|
||||
var remoteIp = ((IPEndPoint)stream.Socket.RemoteEndPoint!).Address;
|
||||
_UpdateConnectionCache(host, port, remoteIp, now);
|
||||
LogWrapper.Debug(ModuleName, $"Connected to {host} via {remoteIp}");
|
||||
|
||||
// 取消其他连接
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
cancellationSource.Cancel();
|
||||
_ = _CleanupUnusedConnectionsAsync(connectionTasks, winner).ConfigureAwait(false);
|
||||
|
||||
return stream;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 清理所有连接
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
cancellationSource.Cancel();
|
||||
_ = _CleanupUnusedConnectionsAsync(connectionTasks, null).ConfigureAwait(false);
|
||||
|
||||
LogWrapper.Error(ex, ModuleName, $"All connection attempts failed for {host}");
|
||||
throw new HttpRequestException($"Connection failed for {host}", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cancellationSource.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private IPAddress[] _SortAddresses(string host, int port, IPAddress[] addresses, DateTime now)
|
||||
{
|
||||
var cacheKey = (host, port);
|
||||
var addressCache = _connectionCache.GetValueOrDefault(cacheKey);
|
||||
|
||||
var cachedAddresses = new List<IPAddress>();
|
||||
var uncachedAddresses = new List<IPAddress>();
|
||||
|
||||
foreach (var ip in addresses)
|
||||
{
|
||||
if (addressCache is not null &&
|
||||
addressCache.TryGetValue(ip, out var successTime) &&
|
||||
now - successTime <= _CacheDuration)
|
||||
{
|
||||
cachedAddresses.Add(ip);
|
||||
}
|
||||
else
|
||||
{
|
||||
uncachedAddresses.Add(ip);
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存地址: 按成功时间倒序 (最近成功的优先)
|
||||
cachedAddresses.Sort((a, b) =>
|
||||
addressCache![b].CompareTo(addressCache[a]));
|
||||
|
||||
// 非缓存地址: IPv6 优先 + 保持原始顺序
|
||||
var ipv6List = uncachedAddresses
|
||||
.Where(static ip => ip.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
.OrderBy(ip => Array.IndexOf(addresses, ip))
|
||||
.ToList();
|
||||
|
||||
var ipv4List = uncachedAddresses
|
||||
.Where(static ip => ip.AddressFamily == AddressFamily.InterNetwork)
|
||||
.OrderBy(ip => Array.IndexOf(addresses, ip))
|
||||
.ToList();
|
||||
|
||||
// 交错合并,确保有一个 v6 和一个 v4
|
||||
var sortedUncached = new List<IPAddress>();
|
||||
int i = 0, j = 0;
|
||||
while (i < ipv6List.Count || j < ipv4List.Count)
|
||||
{
|
||||
if (i < ipv6List.Count)
|
||||
sortedUncached.Add(ipv6List[i++]);
|
||||
if (j < ipv4List.Count)
|
||||
sortedUncached.Add(ipv4List[j++]);
|
||||
}
|
||||
|
||||
return cachedAddresses
|
||||
.Concat(sortedUncached)
|
||||
.Take(WaitTasks)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private void _UpdateConnectionCache(string host, int port, IPAddress ip, DateTime now)
|
||||
{
|
||||
var cacheKey = (host, port);
|
||||
var addressCache = _connectionCache.GetOrAdd(cacheKey, static _ =>
|
||||
new ConcurrentDictionary<IPAddress, DateTime>());
|
||||
|
||||
// 移除过期条目 (懒清理)
|
||||
foreach (var entry in addressCache)
|
||||
{
|
||||
if (now - entry.Value > _CacheDuration)
|
||||
{
|
||||
addressCache.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新当前IP
|
||||
addressCache[ip] = now;
|
||||
}
|
||||
|
||||
private static async Task<NetworkStream> _DelayedConnectAsync(IPAddress ip, int port, int delay, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
return await _ConnectToAddressAsync(ip, port, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<NetworkStream> _ConnectToAddressAsync(IPAddress ip, int port, CancellationToken cancellationToken)
|
||||
{
|
||||
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
|
||||
{
|
||||
NoDelay = true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(ip, port, cancellationToken).ConfigureAwait(false);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
socket.Dispose();
|
||||
throw new HttpRequestException($"Connection to {ip}:{port} failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task _CleanupUnusedConnectionsAsync(
|
||||
List<Task<NetworkStream>> allTasks,
|
||||
Task<NetworkStream>? winnerTask)
|
||||
{
|
||||
foreach (var task in allTasks.Where(t => t != winnerTask))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (task.IsCompletedSuccessfully)
|
||||
{
|
||||
var stream = await task.ConfigureAwait(false);
|
||||
await stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
else if (task.IsCompleted)
|
||||
{
|
||||
_ = task.Exception;
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = task.ContinueWith(static t =>
|
||||
{
|
||||
if (t.IsCompletedSuccessfully)
|
||||
{
|
||||
t.Result.Dispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = t.Exception;
|
||||
}
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ex, ModuleName, "Dispose connection with an error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpBasicExtension
|
||||
{
|
||||
extension(HttpRequestMessage requestMessage)
|
||||
{
|
||||
public HttpRequestMessage WithHttpVersionOption(Version httpVersion)
|
||||
{
|
||||
requestMessage.Version = httpVersion;
|
||||
return requestMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpContentExtension
|
||||
{
|
||||
extension (HttpRequestMessage requestMessage)
|
||||
{
|
||||
public HttpRequestMessage WithContent(HttpContent content, string? contentType = null)
|
||||
{
|
||||
requestMessage.Content = content;
|
||||
if (contentType is not null) requestMessage.WithHeader("Content-Type", contentType);
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithContent(string content, string? contentType = null)
|
||||
{
|
||||
requestMessage.Content = contentType is null
|
||||
? new StringContent(content, Encoding.UTF8)
|
||||
: new StringContent(content, Encoding.UTF8, contentType);
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithBinaryContent(byte[] content)
|
||||
{
|
||||
requestMessage.Content = new ByteArrayContent(content);
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithJsonContent<T>(T content, string? contentType = null, JsonSerializerOptions? options = null)
|
||||
{
|
||||
requestMessage.Content = new StringContent(
|
||||
JsonSerializer.Serialize(content, options ?? JsonCompat.SerializerOptions),
|
||||
Encoding.UTF8,
|
||||
contentType ?? "application/json");
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithFormContent(string form)
|
||||
{
|
||||
return requestMessage.WithContent(
|
||||
new ByteArrayContent(Encoding.UTF8.GetBytes(form)),
|
||||
"application/x-www-form-urlencoded");
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithFormContent(IEnumerable<KeyValuePair<string, string>> pairs)
|
||||
{
|
||||
requestMessage.Content = new FormUrlEncodedContent(pairs);
|
||||
return requestMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpCookieExtension
|
||||
{
|
||||
extension (HttpRequestMessage requestMessage)
|
||||
{
|
||||
public HttpRequestMessage WithCookie(string name, string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requestMessage);
|
||||
ArgumentNullException.ThrowIfNullOrEmpty(value);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var newCookie = $"{name}={_GetSafeCookieValue(value)}";
|
||||
|
||||
if (requestMessage.Headers.TryGetValues("Cookie", out var existingValues))
|
||||
{
|
||||
var existingCookie = string.Join("; ", existingValues);
|
||||
requestMessage.Headers.Remove("Cookie");
|
||||
requestMessage.Headers.Add("Cookie", $"{existingCookie}; {newCookie}");
|
||||
}
|
||||
else
|
||||
{
|
||||
requestMessage.Headers.Add("Cookie", newCookie);
|
||||
}
|
||||
|
||||
return requestMessage;
|
||||
}
|
||||
}
|
||||
|
||||
private static string _GetSafeCookieValue(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return value;
|
||||
var needsEncoding = value.Any(c => _ForbiddenCookieValueChar.Contains(c) || char.IsControl(c));
|
||||
return needsEncoding ? Uri.EscapeDataString(value) : value;
|
||||
}
|
||||
|
||||
private static readonly char[] _ForbiddenCookieValueChar = [';', ',', ' ', '\r', '\n', '\t', '\0', '=', '"', '\'', '\\', '<', '>'];
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpHeaderHandler
|
||||
{
|
||||
extension (HttpRequestMessage requestMessage)
|
||||
{
|
||||
public HttpRequestMessage WithHeader(string key, string value)
|
||||
{
|
||||
if (key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && requestMessage.Content is not null)
|
||||
requestMessage.Content.Headers.TryAddWithoutValidation(key, value);
|
||||
else
|
||||
requestMessage.Headers.TryAddWithoutValidation(key, value);
|
||||
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithHeaders(IDictionary<string, string> pairs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pairs);
|
||||
|
||||
foreach (var item in pairs)
|
||||
{
|
||||
requestMessage.WithHeader(item.Key, item.Value);
|
||||
}
|
||||
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithHeader(KeyValuePair<string, string> pair) =>
|
||||
requestMessage.WithHeader(pair.Key, pair.Value);
|
||||
|
||||
public HttpRequestMessage WithAuthentication(string scheme, string token)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(scheme);
|
||||
ArgumentException.ThrowIfNullOrEmpty(token);
|
||||
|
||||
requestMessage.Headers.Authorization = new AuthenticationHeaderValue(scheme, token);
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithBearerToken(string token) =>
|
||||
requestMessage.WithAuthentication("Bearer", token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using Microsoft.Win32;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Exts;
|
||||
using PCL.Core.Utils.OS;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public class HttpProxyManager : IWebProxy, IDisposable
|
||||
{
|
||||
public static readonly HttpProxyManager Instance = new();
|
||||
|
||||
public enum ProxyMode
|
||||
{
|
||||
NoProxy,
|
||||
SystemProxy,
|
||||
CustomProxy
|
||||
}
|
||||
|
||||
private readonly object _lock = new();
|
||||
private ProxyMode _mode = ProxyMode.SystemProxy;
|
||||
private readonly WebProxy _customWebProxy = new() { BypassProxyOnLocal = true };
|
||||
private readonly WebProxy _systemWebProxy = new() { BypassProxyOnLocal = true };
|
||||
private const string ProxyRegPathFull = @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings";
|
||||
private const string ProxyRegPath = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
|
||||
private readonly RegistryChangeMonitor _proxyMonitor = new(ProxyRegPath);
|
||||
|
||||
private HttpProxyManager()
|
||||
{
|
||||
RefreshSystemProxy(); // 初始化系统代理
|
||||
_proxyMonitor.Changed += _OnSystemProxyChanged;
|
||||
}
|
||||
|
||||
private void _OnSystemProxyChanged(object? sender, EventArgs e)
|
||||
{
|
||||
RefreshSystemProxy();
|
||||
}
|
||||
|
||||
private enum ProxyProtocol
|
||||
{
|
||||
Http,
|
||||
Socks
|
||||
}
|
||||
|
||||
private record ProxyItem
|
||||
{
|
||||
public ProxyProtocol Protocol;
|
||||
public required string Address;
|
||||
}
|
||||
|
||||
private static ProxyItem[] _GetProxyFromString(string? proxyString)
|
||||
{
|
||||
if (proxyString.IsNullOrWhiteSpace()) return [];
|
||||
|
||||
var ret = new List<ProxyItem>();
|
||||
|
||||
// 形式:http=192.168.1.100:8080;socks=192.168.1.100:1080
|
||||
if (proxyString.Contains('='))
|
||||
{
|
||||
foreach (var segment in proxyString.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var eqIndex = segment.IndexOf('=');
|
||||
if (eqIndex <= 0 || eqIndex >= segment.Length - 1)
|
||||
continue;
|
||||
|
||||
var protocolStr = segment[..eqIndex].Trim();
|
||||
var address = segment[(eqIndex + 1)..].Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
continue;
|
||||
|
||||
ret.Add(new ProxyItem { Protocol = _ParseProtocol(protocolStr), Address = address });
|
||||
}
|
||||
|
||||
return ret.Count > 0 ? [.. ret] : [];
|
||||
}
|
||||
|
||||
// 形式:http://127.0.0.1:1145/ 或者单纯 127.0.0.1:1145
|
||||
if (Uri.TryCreate(proxyString, new UriCreationOptions(), out var proxyAddr))
|
||||
{
|
||||
var address = proxyAddr.Port > 0
|
||||
? $"{proxyAddr.Host}:{proxyAddr.Port}"
|
||||
: proxyAddr.Host;
|
||||
ret.Add(new ProxyItem { Protocol = _ParseProtocol(proxyAddr.Scheme), Address = address });
|
||||
}
|
||||
else
|
||||
{
|
||||
ret.Add(new ProxyItem { Protocol = ProxyProtocol.Http, Address = proxyString.Trim() });
|
||||
}
|
||||
|
||||
return [.. ret];
|
||||
}
|
||||
|
||||
private static ProxyProtocol _ParseProtocol(string scheme)
|
||||
{
|
||||
return scheme.ToLowerInvariant() switch
|
||||
{
|
||||
"socks" or "socks4" or "socks5" => ProxyProtocol.Socks,
|
||||
_ => ProxyProtocol.Http
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>刷新系统代理设置</summary>
|
||||
public void RefreshSystemProxy()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
// read from reg
|
||||
var isSystemProxyEnabled = (int)(Registry.GetValue(ProxyRegPathFull, "ProxyEnable", 0) ?? 0);
|
||||
var systemProxyString = Registry.GetValue(ProxyRegPathFull, "ProxyServer", string.Empty) as string;
|
||||
|
||||
// parse
|
||||
var proxies = _GetProxyFromString(systemProxyString);
|
||||
|
||||
// filter
|
||||
if (proxies.Length == 0 || !proxies.Any(static x => x.Protocol.Equals(ProxyProtocol.Http))) isSystemProxyEnabled = 0;
|
||||
var selectedProxy = proxies.FirstOrDefault(static x => x.Protocol.Equals(ProxyProtocol.Http));
|
||||
|
||||
// apply
|
||||
_systemWebProxy.Address = (isSystemProxyEnabled == 0 || selectedProxy!.Address.IsNullOrEmpty())
|
||||
? null
|
||||
: new Uri($"http://{selectedProxy.Address}");
|
||||
|
||||
LogWrapper.Info("Proxy",
|
||||
$"已从操作系统更新代理设置,系统代理状态:{isSystemProxyEnabled}|{systemProxyString}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Proxy", "获取系统代理时出现异常");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ProxyMode Mode
|
||||
{
|
||||
get { lock (_lock) return _mode; }
|
||||
set { lock (_lock) _mode = value; }
|
||||
}
|
||||
|
||||
public Uri? CustomProxyAddress
|
||||
{
|
||||
get { lock (_lock) return _customWebProxy.Address; }
|
||||
set { lock (_lock) _customWebProxy.Address = value; }
|
||||
}
|
||||
|
||||
public ICredentials? CustomProxyCredentials
|
||||
{
|
||||
get { lock (_lock) return _customWebProxy.Credentials; }
|
||||
set { lock (_lock) _customWebProxy.Credentials = value; }
|
||||
}
|
||||
|
||||
public bool BypassOnLocal
|
||||
{
|
||||
get { lock (_lock) return field; }
|
||||
set
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
field = value;
|
||||
_systemWebProxy.BypassProxyOnLocal = value;
|
||||
}
|
||||
}
|
||||
} = true;
|
||||
|
||||
public Uri? GetProxy(Uri destination)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _mode switch
|
||||
{
|
||||
ProxyMode.NoProxy => null, // 返回 null 表明没有代理
|
||||
ProxyMode.SystemProxy => _systemWebProxy.GetProxy(destination),
|
||||
ProxyMode.CustomProxy => _customWebProxy.GetProxy(destination),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsBypassed(Uri host)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _mode switch
|
||||
{
|
||||
ProxyMode.NoProxy => true,
|
||||
ProxyMode.SystemProxy => _systemWebProxy.IsBypassed(host),
|
||||
ProxyMode.CustomProxy => _customWebProxy.IsBypassed(host),
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public ICredentials? Credentials
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// 仅 CustomProxy 模式返回凭据
|
||||
return _mode == ProxyMode.CustomProxy
|
||||
? _customWebProxy.Credentials
|
||||
: null;
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_customWebProxy.Credentials = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_proxyMonitor.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpRequest
|
||||
{
|
||||
public static HttpRequestMessage Create(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Get, new Uri(url));
|
||||
}
|
||||
public static HttpRequestMessage CreateHead(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Head, new Uri(url));
|
||||
}
|
||||
public static HttpRequestMessage CreatePost(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Post, new Uri(url));
|
||||
}
|
||||
public static HttpRequestMessage CreatePut(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Put, new Uri(url));
|
||||
}
|
||||
public static HttpRequestMessage CreateDelete(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Delete, new Uri(url));
|
||||
}
|
||||
|
||||
public static async Task<string> GetStringAsync(string url)
|
||||
{
|
||||
using var resp = await Create(url).SendAsync().ConfigureAwait(false);
|
||||
return await resp.AsStringAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<T?> GetJsonAsync<T>(string url)
|
||||
{
|
||||
using var resp = await Create(url).SendAsync().ConfigureAwait(false);
|
||||
return await resp.AsJsonAsync<T>().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> PostJsonAsync<T>(string url, T data, string? contentType = null)
|
||||
{
|
||||
return await CreatePost(url)
|
||||
.WithJsonContent(data, contentType)
|
||||
.SendAsync()
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public class HttpResponseException:HttpRequestException,IDisposable
|
||||
{
|
||||
public HttpResponseMessage? Response { get; init; }
|
||||
|
||||
public HttpResponseException()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public HttpResponseException(string message) : base(message)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public HttpResponseException(string message, Exception? inner) : base(message, inner)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public HttpResponseException(HttpResponseMessage? response) : this(
|
||||
$"{(int?)response?.StatusCode} {response?.ReasonPhrase ?? (response is null ? "undefined" : Enum.GetName(response.StatusCode))})")
|
||||
{
|
||||
Response = response;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Response?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
~HttpResponseException()
|
||||
{
|
||||
try
|
||||
{
|
||||
Response?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Suppress Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpResponseExtension
|
||||
{
|
||||
extension(HttpResponseMessage responseMessage)
|
||||
{
|
||||
public bool IsSuccess => responseMessage.IsSuccessStatusCode;
|
||||
|
||||
public string AsString() { return responseMessage.AsStringAsync().GetAwaiter().GetResult(); }
|
||||
|
||||
public async Task<string> AsStringAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await responseMessage.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
} catch(TaskCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The request was canceled due to a timeout.");
|
||||
} catch(OperationCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The operation was canceled.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Stream> AsStreamAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await responseMessage.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
} catch(TaskCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The request was canceled due to a timeout.");
|
||||
} catch(OperationCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The operation was canceled.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<byte[]> AsByteArrayAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await responseMessage.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
} catch(TaskCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The request was canceled due to a timeout.");
|
||||
} catch(OperationCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The operation was canceled.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T?> AsJsonAsync<T>(
|
||||
JsonSerializerOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = await responseMessage.AsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<T>(stream, options ?? JsonCompat.SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
} catch(JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException("Failed to deserialize JSON response.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, string[]> GetHeaders()
|
||||
{
|
||||
return responseMessage.Headers
|
||||
.Concat(responseMessage.Content.Headers)
|
||||
.ToDictionary(k => k.Key, v => v.Value.ToArray());
|
||||
}
|
||||
|
||||
public Dictionary<string, string[]> GetContentHeaders()
|
||||
{ return responseMessage.Content.Headers.ToDictionary(k => k.Key, v => v.Value.ToArray()); }
|
||||
|
||||
public string[] GetHeader(string name)
|
||||
{
|
||||
if(responseMessage.Headers.TryGetValues(name, out var values) ||
|
||||
responseMessage.Content.Headers.TryGetValues(name, out values))
|
||||
return values.ToArray();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public bool TryGetHeader(string name, out string[] values)
|
||||
{
|
||||
if(responseMessage.Headers.TryGetValues(name, out var headerValues) ||
|
||||
responseMessage.Content.Headers.TryGetValues(name, out headerValues))
|
||||
{
|
||||
values = headerValues.ToArray();
|
||||
return true;
|
||||
}
|
||||
|
||||
values = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
public string? GetFirstHeaderValue(string name) { return responseMessage.GetHeader(name).FirstOrDefault(); }
|
||||
|
||||
public bool TryGetFirstHeaderValue(string name, out string value)
|
||||
{
|
||||
var values = responseMessage.GetHeader(name);
|
||||
if(values.Length == 0)
|
||||
{
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
value = values.First();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void EnsureSuccessStatusCode()
|
||||
{
|
||||
if(!responseMessage.IsSuccess)
|
||||
throw new HttpRequestException(
|
||||
$"HTTP request failed with status code {responseMessage.StatusCode}: {responseMessage.ReasonPhrase}");
|
||||
}
|
||||
|
||||
public async Task EnsureSuccessStatusCodeWithContentAsync(CancellationToken ct = default)
|
||||
{
|
||||
if(!responseMessage.IsSuccess)
|
||||
{
|
||||
var content = await responseMessage
|
||||
.AsStringAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
throw new HttpRequestException(
|
||||
$"HTTP request failed with status code {responseMessage.StatusCode}: {responseMessage.ReasonPhrase}. Response content: {content}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public class HttpRoute(HttpMethod method, string path) : Attribute
|
||||
{
|
||||
public HttpMethod Method { get; } = method;
|
||||
public string Path { get; } = path;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
/// <summary>
|
||||
/// 用于 <see cref="HttpServer"/> 响应客户端请求的服务端响应结构。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class HttpRouteResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// HTTP 状态码
|
||||
/// </summary>
|
||||
public HttpStatusCode? StatusCode = null;
|
||||
|
||||
/// <summary>
|
||||
/// 此响应 <see cref="InputStream"/> 使用的字符编码
|
||||
/// </summary>
|
||||
public Encoding? ContentEncoding = null;
|
||||
|
||||
/// <summary>
|
||||
/// [Header] 内容 MIME 类型
|
||||
/// </summary>
|
||||
public string? ContentType = null;
|
||||
|
||||
/// <summary>
|
||||
/// [Header] 重定向目标 URL 或路径。
|
||||
/// </summary>
|
||||
public string? RedirectLocation = null;
|
||||
|
||||
/// <summary>
|
||||
/// [Header] 是否使用分块传输编码。
|
||||
/// </summary>
|
||||
public bool? SendChunked = null;
|
||||
|
||||
/// <summary>
|
||||
/// 随响应添加的 Cookies。
|
||||
/// </summary>
|
||||
public CookieCollection? Cookies = null;
|
||||
|
||||
/// <summary>
|
||||
/// 用于传输响应内容的输入流,若非空值,该流将被直接 <c>CopyTo</c> 到实际响应的 <c>OutputStream</c> 中。
|
||||
/// </summary>
|
||||
public Stream? InputStream = null;
|
||||
|
||||
/// <summary>
|
||||
/// 向标准 <see cref="HttpListener"/> 的响应对象写入数据。
|
||||
/// </summary>
|
||||
/// <param name="target">目标对象</param>
|
||||
public void Pour(HttpListenerResponse target)
|
||||
{
|
||||
target.StatusCode = (int)(StatusCode ?? HttpStatusCode.OK);
|
||||
if (ContentType is {} contentType) target.ContentType = contentType;
|
||||
if (ContentEncoding is {} contentEncoding) target.ContentEncoding = contentEncoding;
|
||||
if (RedirectLocation is {} redirectLocation) target.RedirectLocation = redirectLocation;
|
||||
if (SendChunked is {} sendChunked) target.SendChunked = sendChunked;
|
||||
if (Cookies is {} cookies) target.Cookies = cookies;
|
||||
if (InputStream is {} inputStream) inputStream.CopyTo(target.OutputStream);
|
||||
}
|
||||
|
||||
public Task<HttpRouteResponse> AsTask() => Task.FromResult(this);
|
||||
|
||||
/// <summary>
|
||||
/// 返回指定 HTTP 状态码的空响应
|
||||
/// </summary>
|
||||
/// <param name="statusCode">HTTP 状态码</param>
|
||||
public static HttpRouteResponse Empty(HttpStatusCode statusCode) => new() { StatusCode = statusCode };
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 204 (No Content) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse NoContent = Empty(HttpStatusCode.NoContent);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 400 (Bad Request) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse BadRequest = Empty(HttpStatusCode.BadRequest);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 403 (Forbidden) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse Forbidden = Empty(HttpStatusCode.Forbidden);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 404 (Not Found) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse NotFound = Empty(HttpStatusCode.NotFound);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 500 (Internal Server Error) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse InternalServerError = Empty(HttpStatusCode.InternalServerError);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 502 (Bad Gateway) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse BadGateway = Empty(HttpStatusCode.BadGateway);
|
||||
|
||||
/// <summary>
|
||||
/// 响应指定输入流的内容。
|
||||
/// </summary>
|
||||
/// <param name="stream">输入流</param>
|
||||
/// <param name="contentType">内容 MIME 类型</param>
|
||||
/// <param name="encoding">输入流内容使用的字符编码,默认为 UTF-8</param>
|
||||
public static HttpRouteResponse Input(Stream stream, string contentType = "application/octet-stream", Encoding? encoding = null) =>
|
||||
new() { InputStream = stream, ContentType = contentType, ContentEncoding = encoding ?? Encoding.UTF8 };
|
||||
|
||||
/// <summary>
|
||||
/// 响应指定文本内容。
|
||||
/// </summary>
|
||||
/// <param name="text">文本内容</param>
|
||||
/// <param name="contentType">内容 MIME 类型</param>
|
||||
/// <param name="encoding">响应流使用的字符编码,默认为 UTF-8</param>
|
||||
public static HttpRouteResponse Text(string text, string contentType = "text/plain", Encoding? encoding = null) =>
|
||||
Input(new StringStream(text, encoding), contentType, encoding);
|
||||
|
||||
/// <summary>
|
||||
/// 响应重定向。
|
||||
/// </summary>
|
||||
/// <param name="location">重定向目标 URL 或路径</param>
|
||||
/// <param name="statusCode">重定向状态码</param>
|
||||
public static HttpRouteResponse Redirect(string location, HttpStatusCode statusCode = HttpStatusCode.Found) =>
|
||||
new() { StatusCode = statusCode, RedirectLocation = location };
|
||||
|
||||
/// <summary>
|
||||
/// 响应指定对象序列化得到的 JSON 内容,固定使用 UTF-8 编码
|
||||
/// </summary>
|
||||
/// <param name="obj">用于序列化的对象</param>
|
||||
/// <param name="options">JSON 序列化选项</param>
|
||||
public static HttpRouteResponse Json(object obj, JsonSerializerOptions? options)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
JsonSerializer.Serialize(stream, obj, options ?? JsonCompat.SerializerOptions);
|
||||
stream.Position = 0;
|
||||
return new HttpRouteResponse
|
||||
{
|
||||
ContentEncoding = Encoding.UTF8,
|
||||
ContentType = "application/json, charset=utf-8",
|
||||
InputStream = stream
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 响应指定对象序列化得到的 JSON 内容,固定使用 UTF-8 编码
|
||||
/// </summary>
|
||||
/// <param name="obj">用于序列化的对象</param>
|
||||
public static HttpRouteResponse Json(object obj) => Json(obj, JsonCompat.SerializerOptions);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Exts;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpSenderExtension
|
||||
{
|
||||
extension(HttpRequestMessage requestMessage)
|
||||
{
|
||||
public async Task<HttpResponseMessage> SendAsync(
|
||||
HttpClient? httpClient = null,
|
||||
bool addMetedata = true,
|
||||
bool enableLogging = true,
|
||||
HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
|
||||
int retryTimes = 3,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = requestMessage;
|
||||
httpClient ??= NetworkService.GetClient();
|
||||
|
||||
if(addMetedata)
|
||||
{
|
||||
request
|
||||
.WithHeader("User-Agent", $"PCL-Community/PCL2-CE/{Basics.VersionName} (pclc.cc)")
|
||||
.WithHeader("Referer", $"https://{Basics.VersionCode}.ce.open.pcl2.server/");
|
||||
}
|
||||
|
||||
var requestId = Guid.NewGuid().ToString();
|
||||
if (enableLogging)
|
||||
LogWrapper.Info(
|
||||
"Request",
|
||||
$"Send request to {request.RequestUri} (method = {request.Method}, id = {requestId})");
|
||||
|
||||
var resp = await NetworkService.GetRetryPolicy(retryTimes)
|
||||
.ExecuteAsync(
|
||||
async token =>
|
||||
{
|
||||
if (enableLogging)
|
||||
LogWrapper.Debug("Request", $"Try attempt (id = {requestId})");
|
||||
try
|
||||
{
|
||||
using var requestCopy = await request
|
||||
.CloneAsync()
|
||||
.ConfigureAwait(false);
|
||||
return await httpClient
|
||||
.SendAsync(requestCopy, httpCompletionOption, token)
|
||||
.ConfigureAwait(false);
|
||||
}catch(Exception ex)
|
||||
{
|
||||
LogWrapper.Debug(ex, "Request", $"Try attempt failed (id = {requestId})");
|
||||
throw;
|
||||
}
|
||||
},cancellationToken
|
||||
)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (enableLogging)
|
||||
LogWrapper.Info("Request", $"End request, got http status code {resp.StatusCode} (id = {requestId})");
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public abstract class HttpServer : IDisposable
|
||||
{
|
||||
private readonly HttpListener _server = new();
|
||||
public readonly ushort Port;
|
||||
public readonly string[] Host;
|
||||
|
||||
private Task? _handleLoop;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private readonly Dictionary<(HttpMethod method, string path), Func<HttpListenerRequest, Task<HttpRouteResponse>>> _handlers = new();
|
||||
private bool _initialized = false;
|
||||
|
||||
protected HttpServer(IPAddress[] listenAddr, ushort port = 0)
|
||||
{
|
||||
// Check parameters
|
||||
ArgumentNullException.ThrowIfNull(listenAddr);
|
||||
|
||||
// Resolve port
|
||||
if (port == 0) port = (ushort)NetworkHelper.NewTcpPort();
|
||||
Port = port;
|
||||
|
||||
// Resolve host
|
||||
if (listenAddr.Length == 0)
|
||||
listenAddr = [IPAddress.Loopback, IPAddress.IPv6Loopback];
|
||||
|
||||
var hosts = new List<string>();
|
||||
foreach (var address in listenAddr)
|
||||
{
|
||||
_server.Prefixes.Add($"http://{address}:{port}/");
|
||||
hosts.Add(address.ToString());
|
||||
}
|
||||
Host = hosts.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化路由。子类应在此方法中调用 Register 方法注册路由。
|
||||
/// </summary>
|
||||
protected abstract void Init();
|
||||
|
||||
/// <summary>
|
||||
/// 注册一个路由处理器。
|
||||
/// </summary>
|
||||
/// <param name="method">HTTP 方法</param>
|
||||
/// <param name="path">路由路径</param>
|
||||
/// <param name="handler">请求处理函数</param>
|
||||
protected void Register(HttpMethod method, string path, Func<HttpListenerRequest, Task<HttpRouteResponse>> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(method);
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
_handlers[(method, path)] = handler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动 HTTP 服务器。
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
// 如果没有注册路由,调用 Init 初始化
|
||||
if (!_initialized && _handlers.Count == 0)
|
||||
{
|
||||
Init();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
_server.Start();
|
||||
_handleLoop = _HandleRequestAsync();
|
||||
}
|
||||
|
||||
private async Task _HandleRequestAsync()
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = await _server.GetContextAsync();
|
||||
_ = Task.Run(async () => await _ProcessRequestAsync(context), cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) { break; } // Cancellation
|
||||
catch (ObjectDisposedException) { break; } // Disposed
|
||||
catch (HttpListenerException) { break; } // Closed
|
||||
}
|
||||
}
|
||||
|
||||
private async Task _ProcessRequestAsync(HttpListenerContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = context.Request;
|
||||
var response = context.Response;
|
||||
var path = request.Url?.AbsolutePath ?? string.Empty;
|
||||
var method = new HttpMethod(request.HttpMethod);
|
||||
|
||||
// 首先尝试精确匹配
|
||||
if (_handlers.TryGetValue((method, path), out var handler))
|
||||
{
|
||||
await _ExecuteHandlerAsync(handler, request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果没有精确匹配,尝试通配符匹配
|
||||
if (_handlers.TryGetValue((method, "*"), out var wildcardHandler))
|
||||
{
|
||||
await _ExecuteHandlerAsync(wildcardHandler, request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 没有找到匹配的路由
|
||||
response.StatusCode = (int)HttpStatusCode.NotFound;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
context.Response.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors when closing response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task _ExecuteHandlerAsync(Func<HttpListenerRequest, Task<HttpRouteResponse>> handler, HttpListenerRequest request, HttpListenerResponse response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var routeResponse = await handler(request);
|
||||
routeResponse.Pour(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
response.ContentEncoding = System.Text.Encoding.UTF8;
|
||||
response.ContentType = "text/plain";
|
||||
var errorResponse =
|
||||
HttpRouteResponse.Text($"Internal Server Error:\n{ex}", "text/plain", System.Text.Encoding.UTF8);
|
||||
errorResponse.Pour(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止 HTTP 服务器。
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_server.Stop();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
Stop();
|
||||
_server.Close();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace PCL.Core.IO.Net;
|
||||
|
||||
public static class NetworkHelper
|
||||
{
|
||||
public static int NewTcpPort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
public static bool IsNetworkAvailable()
|
||||
{
|
||||
return NetworkInterface.GetIsNetworkAvailable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace PCL.Core.IO.Net;
|
||||
|
||||
public static class NetworkInterfaceUtils
|
||||
{
|
||||
public static List<NetworkInterface> GetAvailableInterface()
|
||||
{
|
||||
return NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(iface => !_IsVirtualInterface(iface))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public enum IPv6Status
|
||||
{
|
||||
Unknown,
|
||||
Public,
|
||||
RFC4193,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
public static IPv6Status GetIPv6Status()
|
||||
{
|
||||
foreach (var iface in GetAvailableInterface())
|
||||
{
|
||||
var ipv6Addresses = iface.GetIPProperties().UnicastAddresses
|
||||
.Where(addr => addr.Address.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
.Select(addr => addr.Address)
|
||||
.ToArray();
|
||||
|
||||
if (ipv6Addresses.Length == 0)
|
||||
{
|
||||
return IPv6Status.Unavailable;
|
||||
}
|
||||
|
||||
foreach (var ip in ipv6Addresses)
|
||||
{
|
||||
if (_IsPublicIPv6(ip))
|
||||
{
|
||||
return IPv6Status.Public;
|
||||
}
|
||||
if (_IsUniqueLocalIPv6(ip))
|
||||
{
|
||||
return IPv6Status.RFC4193;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return IPv6Status.Unknown;
|
||||
}
|
||||
|
||||
private static bool _IsVirtualInterface(NetworkInterface iface)
|
||||
{
|
||||
// 常见的虚拟接口类型和名称关键词
|
||||
var virtualTypes = new[] {
|
||||
NetworkInterfaceType.Loopback,
|
||||
NetworkInterfaceType.Tunnel,
|
||||
NetworkInterfaceType.Ppp
|
||||
};
|
||||
|
||||
var virtualKeywords = new[] {
|
||||
"virtual",
|
||||
"pseudo",
|
||||
"loopback",
|
||||
"tunnel",
|
||||
"vpn",
|
||||
"ppp",
|
||||
"veth",
|
||||
"docker",
|
||||
"hyper-v",
|
||||
"vmware",
|
||||
"virtualbox"
|
||||
};
|
||||
|
||||
return virtualTypes.Contains(iface.NetworkInterfaceType) ||
|
||||
virtualKeywords.Any(keyword => iface.Description.ToLower().Contains(keyword));
|
||||
}
|
||||
|
||||
private static bool _IsPublicIPv6(IPAddress ip)
|
||||
{
|
||||
byte[] addressBytes = ip.GetAddressBytes();
|
||||
// 公网IPv6地址范围:2000::/3(即首字节在0x20到0x3F之间)
|
||||
return addressBytes[0] >= 0x20 && addressBytes[0] <= 0x3F;
|
||||
}
|
||||
|
||||
private static bool _IsUniqueLocalIPv6(IPAddress ip)
|
||||
{
|
||||
byte[] addressBytes = ip.GetAddressBytes();
|
||||
// 唯一本地地址范围:FC00::/7(即首字节为0xFC或0xFD)
|
||||
return addressBytes[0] == 0xFC || addressBytes[0] == 0xFD;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.App.IoC;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
using PCL.Core.IO.Net.Http.Cache;
|
||||
using PCL.Core.IO.Storage.Cache;
|
||||
using PCL.Core.Logging;
|
||||
using Polly;
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace PCL.Core.IO.Net;
|
||||
|
||||
[LifecycleService(LifecycleState.Loading)]
|
||||
[LifecycleScope("network", "网络服务")]
|
||||
public partial class NetworkService
|
||||
{
|
||||
|
||||
private const int LifeTime = 15;
|
||||
|
||||
#region AddressDefinition
|
||||
|
||||
private const string MicrosoftEntraIdServer = "https://login.microsoftonline.com/";
|
||||
|
||||
private const string MojangPistonMetaServer = "https://piston-meta.mojang.com/";
|
||||
|
||||
private const string MojangSessionServer = "https://sessionserver.mojang.com/";
|
||||
|
||||
private const string CurseForgeApiServer = "https://api.curseforge.com/v1/";
|
||||
|
||||
private const string ModrinthApiServer = "https://api.modrinth.com/v2/";
|
||||
|
||||
private const string MinecraftServiceServer = "https://api.minecraftservices.com/";
|
||||
|
||||
#endregion
|
||||
|
||||
#region HttpClientName
|
||||
|
||||
public const string Default = "default";
|
||||
|
||||
public const string MicrosoftEntraId = "microsoft_id";
|
||||
|
||||
public const string MinecraftService = "minecraft_service";
|
||||
|
||||
public const string Cache = "cache";
|
||||
|
||||
public const string MojangPistonMeta = "mojang_piston";
|
||||
|
||||
public const string MojangSession = "mojang_session";
|
||||
|
||||
public const string CurseForgeApi = "curseforge_api";
|
||||
|
||||
public const string ModrinthApi = "modrinth_api";
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
private static ServiceProvider? _provider;
|
||||
private static IHttpClientFactory? _factory;
|
||||
|
||||
[LifecycleStart]
|
||||
private static void _Start()
|
||||
{
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.ConfigureHttpClientDefaults(b => b
|
||||
.ConfigurePrimaryHttpMessageHandler(_GetSocketsHttpHandler)
|
||||
.ConfigureHttpClient(c => c.DefaultRequestHeaders
|
||||
.UserAgent.Add(new ProductInfoHeaderValue("PCL-CE", Basics.VersionName)))
|
||||
.SetHandlerLifetime(TimeSpan.FromMinutes(LifeTime)));
|
||||
|
||||
// 默认的 HTTP Client
|
||||
|
||||
services.AddHttpClient(Default);
|
||||
|
||||
// CurseForge
|
||||
|
||||
services.AddHttpClient(CurseForgeApi).ConfigureHttpClient(c =>
|
||||
{
|
||||
c.DefaultRequestHeaders.Add("x-api-key", Secrets.CurseForgeAPIKey);
|
||||
c.BaseAddress = new Uri(CurseForgeApiServer);
|
||||
});
|
||||
|
||||
// Modrinth
|
||||
|
||||
services.AddHttpClient(ModrinthApi).ConfigureHttpClient(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(ModrinthApiServer);
|
||||
});
|
||||
|
||||
// Microsoft Entra ID
|
||||
|
||||
services.AddHttpClient(MicrosoftEntraId).ConfigureHttpClient(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(MicrosoftEntraIdServer);
|
||||
});
|
||||
|
||||
// Minecraft Service API
|
||||
|
||||
services.AddHttpClient(MinecraftService).ConfigureHttpClient(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(MinecraftServiceServer);
|
||||
});
|
||||
|
||||
// Mojang Piston Manifest
|
||||
|
||||
services.AddHttpClient(MojangPistonMeta).ConfigureHttpClient(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(MojangPistonMetaServer);
|
||||
});
|
||||
|
||||
// Mojang Session Server
|
||||
|
||||
services.AddHttpClient(MojangSession).ConfigureHttpClient(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(MojangSessionServer);
|
||||
});
|
||||
|
||||
// Cache
|
||||
services.AddHttpClient(Cache)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpCacheHandler(
|
||||
_GetSocketsHttpHandler(),CacheServiceManager.Current
|
||||
)).SetHandlerLifetime(TimeSpan.FromMinutes(LifeTime));
|
||||
|
||||
_provider?.Dispose();
|
||||
_provider = services.BuildServiceProvider();
|
||||
_factory = _provider.GetRequiredService<IHttpClientFactory>();
|
||||
}
|
||||
|
||||
[LifecycleStop]
|
||||
private static void _Stop()
|
||||
{
|
||||
_provider?.Dispose();
|
||||
}
|
||||
|
||||
private static SocketsHttpHandler _GetSocketsHttpHandler() => new SocketsHttpHandler
|
||||
{
|
||||
UseProxy = true,
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
Proxy = HttpProxyManager.Instance,
|
||||
AllowAutoRedirect = true,
|
||||
MaxAutomaticRedirections = 20,
|
||||
UseCookies = false,
|
||||
ConnectCallback = Config.Network.EnableDoH ? HostConnectionHandler.Instance.GetConnectionAsync : null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 获取 HttpClient
|
||||
/// </summary>
|
||||
/// <param name="wantClientType">指定要求的 HttpClient 来源</param>
|
||||
/// <returns>HttpClient 实例</returns>
|
||||
public static HttpClient GetClient(string wantClientType = "default")
|
||||
{
|
||||
return _factory?.CreateClient(wantClientType) ??
|
||||
throw new InvalidOperationException("在初始化完成前的意外调用");
|
||||
}
|
||||
|
||||
private const int BaseRetryDelayMs = 1000;
|
||||
private const int MaxRetryDelayMs = 30000;
|
||||
|
||||
private static TimeSpan _DefaultSleepDurationProvider(int attempt)
|
||||
{
|
||||
var delayMs = Math.Pow(2, attempt - 1) * BaseRetryDelayMs;
|
||||
delayMs = Math.Min(delayMs, MaxRetryDelayMs);
|
||||
return TimeSpan.FromMilliseconds(delayMs);
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取重试策略
|
||||
/// </summary>
|
||||
/// <param name="retry">最大重试次数</param>
|
||||
/// <param name="retryPolicy">定义重试器行为</param>
|
||||
/// <returns>AsyncPolicy</returns>
|
||||
public static AsyncPolicy GetRetryPolicy(int retry = 3, Func<int, TimeSpan>? retryPolicy = null)
|
||||
{
|
||||
retryPolicy ??= _DefaultSleepDurationProvider;
|
||||
|
||||
return Policy
|
||||
.Handle<HttpRequestException>()
|
||||
.WaitAndRetryAsync(
|
||||
retry,
|
||||
attempt => retryPolicy.Invoke(attempt),
|
||||
onRetry: (exception, timeSpan, retryAttempt, _) =>
|
||||
{
|
||||
LogWrapper.Debug(
|
||||
exception,
|
||||
"Network",
|
||||
$"HTTP 请求失败,正在进行第 {retryAttempt} 次重试,等待 {timeSpan.TotalMilliseconds} 毫秒。"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace PCL.Core.IO.Net;
|
||||
|
||||
public static class SocketExtensions
|
||||
{
|
||||
public static void SafeClose(this Socket? socket)
|
||||
{
|
||||
if (socket is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (socket.Connected)
|
||||
{
|
||||
socket.Shutdown(SocketShutdown.Both);
|
||||
}
|
||||
socket.Close();
|
||||
}
|
||||
catch { /* 忽略关闭时的任何错误 */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Logging;
|
||||
|
||||
namespace PCL.Core.IO.Net;
|
||||
public sealed class TcpForward(
|
||||
IPAddress listenAddress,
|
||||
int listenPort,
|
||||
IPAddress targetAddress,
|
||||
int targetPort,
|
||||
int maxConnections = 10)
|
||||
: IDisposable
|
||||
{
|
||||
private Socket? _listenerSocket;
|
||||
private CancellationTokenSource? _cts;
|
||||
private readonly SemaphoreSlim _connectionSemaphore = new(maxConnections, maxConnections);
|
||||
private readonly ConcurrentDictionary<Guid, ConnectionPair> _activeConnections = new();
|
||||
|
||||
private bool _isRunning;
|
||||
|
||||
public int LocalPort { get; private set; }
|
||||
|
||||
public int ActiveConnections => _activeConnections.Count;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_isRunning) return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_isRunning = true;
|
||||
|
||||
try
|
||||
{
|
||||
// 创建并启动监听 Socket
|
||||
_listenerSocket = new Socket(SocketType.Stream, ProtocolType.Tcp)
|
||||
{
|
||||
NoDelay = true, // 禁用 Nagle 算法以提高响应速度
|
||||
ReceiveBufferSize = 8192,
|
||||
SendBufferSize = 8192
|
||||
};
|
||||
|
||||
_listenerSocket.Bind(new IPEndPoint(listenAddress, listenPort));
|
||||
_listenerSocket.Listen(100); // 设置挂起连接队列的最大长度
|
||||
|
||||
if (_listenerSocket.LocalEndPoint is not IPEndPoint endPoint) throw new InvalidCastException("出现了意外的转换操作");
|
||||
LocalPort = endPoint.Port;
|
||||
|
||||
// 启动 TCP 接受连接任务
|
||||
_ = Task.Run(() => _AcceptConnectionsAsync(_cts.Token), _cts.Token);
|
||||
|
||||
LogWrapper.Info("TcpForward", $"MC 端口转发已启动,监听 {listenAddress}:{LocalPort},目标 {targetAddress}:{targetPort}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_isRunning = false;
|
||||
LogWrapper.Error(ex, "TcpForward", $"启动 MC 端口转发时发生错误: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!_isRunning) return;
|
||||
|
||||
_cts?.Cancel();
|
||||
_isRunning = false;
|
||||
|
||||
// 关闭所有活动连接
|
||||
foreach (var connection in _activeConnections.Values)
|
||||
{
|
||||
connection.ClientSocket.SafeClose();
|
||||
connection.TargetSocket.SafeClose();
|
||||
}
|
||||
_activeConnections.Clear();
|
||||
|
||||
_listenerSocket?.SafeClose();
|
||||
|
||||
LogWrapper.Info("TcpForward", "MC 端口转发已停止");
|
||||
}
|
||||
|
||||
private async Task _AcceptConnectionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.Register(() =>
|
||||
{
|
||||
_listenerSocket.SafeClose();
|
||||
});
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_listenerSocket is null) break;
|
||||
var clientSocket = await _listenerSocket.AcceptAsync(cancellationToken);
|
||||
|
||||
// 检查是否达到最大连接限制
|
||||
if (_activeConnections.Count >= maxConnections)
|
||||
{
|
||||
clientSocket.SafeClose();
|
||||
LogWrapper.Warn("TcpForward", $"已达到最大连接数限制({maxConnections}),拒绝新连接");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用信号量控制并发处理
|
||||
await _connectionSemaphore.WaitAsync(cancellationToken);
|
||||
|
||||
// 异步处理连接,不等待完成
|
||||
_ = Task.Run(() => _HandleConnectionAsync(clientSocket, cancellationToken), cancellationToken)
|
||||
.ContinueWith(_ => _connectionSemaphore.Release(), TaskScheduler.Default);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "TcpForward", $"接受连接时发生错误");
|
||||
await Task.Delay(1000, cancellationToken); // 出错后等待 1 秒再继续
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task _HandleConnectionAsync(Socket clientSocket, CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionId = Guid.NewGuid();
|
||||
|
||||
try
|
||||
{
|
||||
LogWrapper.Info("TcpForward", $"接受来自 {clientSocket.RemoteEndPoint} 的连接");
|
||||
|
||||
// 连接到目标服务器
|
||||
var targetSocket = new Socket(SocketType.Stream, ProtocolType.Tcp)
|
||||
{
|
||||
NoDelay = true,
|
||||
ReceiveBufferSize = 8192,
|
||||
SendBufferSize = 8192
|
||||
};
|
||||
|
||||
await targetSocket.ConnectAsync(targetAddress, targetPort, cancellationToken);
|
||||
|
||||
// 保存连接对
|
||||
var connectionPair = new ConnectionPair(clientSocket, targetSocket);
|
||||
_activeConnections[connectionId] = connectionPair;
|
||||
|
||||
LogWrapper.Info("TcpForward", $"开始端口转发 {clientSocket.RemoteEndPoint} <-> {targetSocket.RemoteEndPoint}({connectionId})");
|
||||
|
||||
// 使用高性能的 SocketAsyncEventArgs 进行双向转发
|
||||
var forwardTask1 = _ForwardDataAsync(clientSocket, targetSocket, cancellationToken);
|
||||
var forwardTask2 = _ForwardDataAsync(targetSocket, clientSocket, cancellationToken);
|
||||
|
||||
// 等待任意一个方向的数据转发完成
|
||||
await Task.WhenAny(forwardTask1, forwardTask2);
|
||||
|
||||
Console.WriteLine($"端口转发 {connectionId} 已完成");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 取消操作,正常退出
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"处理连接 {connectionId} 时发生错误: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 清理资源
|
||||
clientSocket.SafeClose();
|
||||
|
||||
// 从活动连接中移除
|
||||
_activeConnections.TryRemove(connectionId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task _ForwardDataAsync(Socket source, Socket destination, CancellationToken cancellationToken)
|
||||
{
|
||||
using var bufferOwner = MemoryPool<byte>.Shared.Rent(8192);
|
||||
try
|
||||
{
|
||||
var buffer = bufferOwner.Memory;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var bytesRead = await source.ReceiveAsync(buffer, SocketFlags.None, cancellationToken);
|
||||
if (bytesRead == 0) break; // 连接已关闭
|
||||
|
||||
await destination.SendAsync(buffer[..bytesRead], SocketFlags.None, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch {/* 忽略错误 */}
|
||||
}
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void _Dispose(bool disposing)
|
||||
{
|
||||
if (!disposing) return;
|
||||
if (_disposed) return;
|
||||
Stop();
|
||||
_cts?.Dispose();
|
||||
_connectionSemaphore.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
~TcpForward()
|
||||
{
|
||||
_Dispose(false);
|
||||
}
|
||||
|
||||
private class ConnectionPair(Socket clientSocket, Socket targetSocket)
|
||||
{
|
||||
public Socket ClientSocket { get; } = clientSocket;
|
||||
public Socket TargetSocket { get; } = targetSocket;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user