From ccb51a6b06fabec499da089076c86d6f0d9caf7a Mon Sep 17 00:00:00 2001 From: TitleHHHH Date: Fri, 6 Mar 2026 23:23:34 +0500 Subject: [PATCH 1/5] refactor: remove dead code, DotNext, migrate to .slnx, fix HTTP parsing bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete QuickProxyNet.Pipelines (Pipelines experiment for SOCKS, not useful) - Migrate QuickProxyNet.sln → QuickProxyNet.slnx - Remove ZString and DotNext dependencies; replace with BCL (ArrayPool, StringBuilder) - Fix HttpResponseParser: IndexOf not LastIndexOf, correct over-read byte handling - Add PrefixedStream to serve over-read bytes before delegating to inner stream - Fix HttpsProxyClient: remove deprecated ServicePointManager, dead SslCertificateValidationInfo/SslChainElement, duplicate SslStream callback - Fix ProxyClient nullable warnings (LingerState, CancellationToken.Register lambda) - Delete dead files: ConnectHelper, CancellationHelper, Http3ErrorCode, RequestRetryType, SslClientAuthenticationOptionsExtensions, SocketHelper, Ext - Update deps: xunit→2.9.3, xunit.runner.visualstudio→3.0.0, BenchmarkDotNet→0.15.8 - Update tests to use GetStatusCode() and ToString() on HttpResponseParser - Add CLAUDE.md project documentation Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 166 +++++++++++++++++ QuickProxyNet.Benchmarks/Benchmarks.cs | 142 ++------------- .../QuickProxyNet.Benchmarks.csproj | 9 +- QuickProxyNet.Tests/InternalTest.cs | 12 +- .../QuickProxyNet.Tests.csproj | 8 +- QuickProxyNet.sln | 47 ----- QuickProxyNet.slnx | 9 + QuickProxyNet/Clients/HttpsProxyClient.cs | 106 ++--------- QuickProxyNet/Clients/ProxyClient.cs | 5 +- QuickProxyNet/Ext.cs | 27 --- QuickProxyNet/Internal/CancellationHelper.cs | 53 ------ QuickProxyNet/Internal/ConnectHelper.cs | 123 ------------- QuickProxyNet/Internal/Http3ErrorCode.cs | 108 ----------- QuickProxyNet/Internal/HttpHelper.cs | 171 +++++++++++------- QuickProxyNet/Internal/HttpResponseParser.cs | 126 +++++++------ QuickProxyNet/Internal/RequestRetryType.cs | 30 --- ...slClientAuthenticationOptionsExtensions.cs | 83 --------- QuickProxyNet/QuickProxyNet.csproj | 2 - QuickProxyNet/SocketHelper.cs | 5 - 19 files changed, 396 insertions(+), 836 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 QuickProxyNet.sln create mode 100644 QuickProxyNet.slnx delete mode 100644 QuickProxyNet/Ext.cs delete mode 100644 QuickProxyNet/Internal/CancellationHelper.cs delete mode 100644 QuickProxyNet/Internal/ConnectHelper.cs delete mode 100644 QuickProxyNet/Internal/Http3ErrorCode.cs delete mode 100644 QuickProxyNet/Internal/RequestRetryType.cs delete mode 100644 QuickProxyNet/Internal/SslClientAuthenticationOptionsExtensions.cs delete mode 100644 QuickProxyNet/SocketHelper.cs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..679f87a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,166 @@ +# QuickProxyNet — CLAUDE.md + +## Project Overview + +**QuickProxyNet** is a high-performance C# .NET library for connecting to servers through proxy protocols (HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5). It provides direct `Stream` access for low-level networking with minimal allocations and latency. + +- **NuGet package:** `QuickProxyNet` +- **Author:** Titlehhhh +- **License:** MIT +- **Targets:** net8.0, net9.0, net10.0 + +## Solution Structure + +``` +QuickProxyNet/ — Core library (public API + internal protocol logic) +QuickProxyNet.Tests/ — XUnit tests (net8.0) +QuickProxyNet.Benchmarks/— BenchmarkDotNet benchmarks (net8.0) +QuickProxyNet.Pipelines/ — Experimental System.IO.Pipelines rewrite (net8.0) +Sample/ — Usage example console app +build/ — Nuke build automation +``` + +## Core Architecture + +**Namespace:** `QuickProxyNet` (all public types) + +### Public API + +- **`IProxyClient`** — main interface: `ConnectAsync(host, port, ...)` returns `ValueTask` +- **`ProxyClient`** — abstract base with socket creation, timeout, and error handling +- **`ProxyClientFactory`** — singleton factory: creates clients from `Uri` or explicit parameters +- **`ProxyType`** — enum: `Http`, `Https`, `Socks4`, `Socks4a`, `Socks5` +- **`ProxyProtocolException`** — custom exception with `ProxyErrorCode` enum + +### Client Implementations (`QuickProxyNet/Clients/`) + +| Class | Protocol | +|---|---| +| `HttpProxyClient` | HTTP CONNECT tunnel | +| `HttpsProxyClient` | HTTPS CONNECT + SSL/TLS | +| `Socks4Client` | SOCKS4 (IP only) | +| `Socks4aClient` | SOCKS4a (domain names) | +| `Socks5Client` | SOCKS5 (full, with auth) | + +### Internal Helpers (`QuickProxyNet/Internal/`) + +- **`SocksHelper.cs`** — SOCKS4/4a/5 binary protocol (RFC-compliant, 313 lines) +- **`HttpHelper.cs`** — HTTP CONNECT with `PreallocatedStream` for header reuse (314 lines) +- **`ProxyConnector.cs`** — routes to the right tunnel method +- **`HttpResponseParser.cs`** — HTTP response parsing +- **`ConnectHelper.cs`** — SSL/TLS helpers with cert validation mapping +- **`CancellationHelper.cs`** — cancellation + exception utilities + +## Key Dependencies + +| Package | Purpose | +|---|---| +| `DotNext` 5.25.x | Advanced .NET utilities (buffers, memory, text) | +| `DotNext.IO` 5.25.x | I/O pipeline utilities, `SpanWriter` | +| `ZString` 2.6.0 | Zero-alloc string building (Cysharp) | +| `ConfigureAwait.Fody` | IL weaving — ConfigureAwait on all awaits | +| `System.IO.Pipelines` | Pipelines project only | + +## Code Style & Patterns + +### C# Settings (all projects) +- `ImplicitUsings`, `Nullable`, `LangVersion: latest` +- `AllowUnsafeBlocks: true` (performance-critical paths) + +### Performance Patterns (follow these in all changes) +- **`ValueTask`** everywhere for async — no unnecessary `Task` allocations +- **`ArrayPool.Shared.Rent/Return`** for temporary buffers +- **`stackalloc`** for small stack buffers (`stackalloc char[256]`) +- **`ReadOnlySpan` / `Memory`** for buffer slices +- **`SpanWriter`** (from DotNext) for binary protocol writing +- **`[MethodImpl(AggressiveInlining | AggressiveOptimization)]`** on hot paths +- **`PreallocatedStream`** pattern to recycle response buffers without allocation +- **`BinaryPrimitives.WriteUInt16BigEndian`** for big-endian network byte order + +### Architecture Patterns +- Factory pattern (`ProxyClientFactory`) +- Template method / abstract base (`ProxyClient`) +- Strategy pattern (proxy type selection) +- Internal implementation hidden behind `internal` keyword + +### Error Handling +- All proxy errors → `ProxyProtocolException` with specific `ProxyErrorCode` +- Socket exceptions translated to protocol exceptions in `ProxyClient` +- Timeout via `TimeProvider.System.CreateTimer()` + +## Build System + +**NUKE** build automation (`build/Build.cs`). + +```bash +# Run via scripts in repo root: +./build.sh # Linux/macOS +./build.cmd # Windows + +# Key targets: +Restore # Restore NuGet packages +Compile # Build all projects +Tests # Run xUnit tests +Pack # Create NuGet package (Release mode) +Push # Publish to NuGet / GitHub Packages +``` + +Versioning: **GitVersion** 5.12.0 (git-based semantic versioning). + +## Testing + +**Framework:** xUnit 2.5.3, coverlet for coverage + +```bash +dotnet test QuickProxyNet.Tests/ +``` + +Test files: +- `FactoryTest.cs` — URI parsing, credentials, unsupported protocols +- `InternalTest.cs` — HTTP response parser (in progress) +- `ConnectTest.cs` — connection tests (in progress) + +Tests are sparse — prefer adding integration tests for new protocol behavior. + +## Experimental Branch: `experimental/pipelines-lib` + +The `QuickProxyNet.Pipelines/` project rewrites the internals using `System.IO.Pipelines`: +- Uses `IDuplexPipe` instead of `Stream` +- `IBufferWriter` + `SpanWriter` for writing +- Sequence-based reading (eliminates manual `ArrayPool` management) +- Currently covers SOCKS4/4a protocol; work in progress + +## Important Notes for Development + +1. **No LINQ** in hot paths — allocates enumerators. +2. **No `async void`** — always use `async Task` or `async ValueTask`. +3. **Always release `ArrayPool` rentals** in `finally` blocks. +4. **Public API must be XML-documented** — `GenerateDocumentationFile` is enabled. +5. **ConfigureAwait** is handled by Fody weaving — do not add manually. +6. **Multi-targeting** — changes in `QuickProxyNet/` must be compatible with net8, net9, net10. +7. **`ProxyErrorCode`** — add new codes there before throwing new exception types. +8. When editing protocol logic, validate against the relevant RFC: + - SOCKS4/4a: no official RFC, de-facto standard + - SOCKS5: RFC 1928 + RFC 1929 (auth) + - HTTP CONNECT: RFC 9110 + +## Common Tasks + +### Add a new proxy type +1. Add value to `ProxyType` enum +2. Create `NewProxyClient.cs` in `Clients/` extending `ProxyClient` +3. Register in `ProxyClientFactory` switch +4. Add `ProxyErrorCode` values as needed +5. Write tests in `FactoryTest.cs` and a connection test + +### Add/modify protocol helper +- Edit `Internal/SocksHelper.cs` or `Internal/HttpHelper.cs` +- Keep all types `internal` +- Prefer `SpanWriter` over manual array indexing +- Use `ReadExactlyAsync()` (from `Ext.cs`) for exact-length reads + +### Run benchmarks +```bash +cd QuickProxyNet.Benchmarks +dotnet run -c Release +``` diff --git a/QuickProxyNet.Benchmarks/Benchmarks.cs b/QuickProxyNet.Benchmarks/Benchmarks.cs index e507c45..74f6c0b 100644 --- a/QuickProxyNet.Benchmarks/Benchmarks.cs +++ b/QuickProxyNet.Benchmarks/Benchmarks.cs @@ -1,128 +1,26 @@ -using System; -using System.Buffers; -using System.Globalization; +using System; using System.Text; -using BenchmarkDotNet; using BenchmarkDotNet.Attributes; -using Cysharp.Text; -using DotNext; -using DotNext.Buffers; -using DotNext.Buffers.Text; -using DotNext.Text; -using Microsoft.Extensions.Primitives; -namespace QuickProxyNet.Benchmarks -{ - [MemoryDiagnoser] - public class WriteConnectCommand - { - public static string Host = "example.com"; - public static int Port = 25565; - - public static string User = "asdnjakgvauyedgfuysgdjfhbsdghfgsdv"; - public static string Pass = "asdklasdkjadhfkjsdhgiuhskfgjdnhsduhfisbfd"; - - private static readonly char[] line1 = "Proxy-Authorization: Basic ".ToCharArray(); - private static readonly char[] newLine = "\r\n".ToCharArray(); - - - [Benchmark] - public ReadOnlyMemory StringBuilder() - { - StringBuilder sb = new(); - sb.Append($"CONNECT {Host}:{Port} HTTP/1.1\r\n"); - sb.Append($"Host: {Host}:{Port}\r\n"); - //if (false) - { - var token = Encoding.UTF8.GetBytes($"{User}:{Pass}"); - var base64 = Convert.ToBase64String(token); - sb.Append($"Proxy-Authorization: Basic {base64}\r\n"); - sb.Append("\r\n"); - } - - sb.Append("\r\n"); - string s = sb.ToString(); - - - return Encoding.UTF8.GetBytes(s.AsSpan()).Memory; - } - - [Benchmark] - public ReadOnlyMemory ZStringBench() - { - var builder = ZString.CreateUtf8StringBuilder(); - try - { - builder.AppendFormat("CONNECT {0}:{1} HTTP/1.1\r\n", Host, Port); - builder.AppendFormat("Host: {0}:{1}\r\n", Host, Port); - // if (false) - { - MemoryOwner token = Encoding.UTF8.GetBytes($"{User}:{Pass}".AsSpan(), s_allocator); - - int len = (int)(((uint)token.Length + 2) / 3 * 4); - - - MemoryOwner chars = s_allocator_char.AllocateExactly(len); - Convert.TryToBase64Chars(token.Span, chars.Span, out int written); - - chars.TryResize(written); - - builder.Append(line1.AsSpan()); - builder.Append(chars.Span); - builder.Append(newLine.AsSpan()); +namespace QuickProxyNet.Benchmarks; - chars.Dispose(); - - - token.Dispose(); - } - - builder.Append(newLine.AsSpan()); - return builder.AsMemory(); - } - finally - { - builder.Dispose(); - } - } - - private static MemoryAllocator s_allocator = ArrayPool.Shared.ToAllocator(); - private static MemoryAllocator s_allocator_char = ArrayPool.Shared.ToAllocator(); - - [Benchmark] - public ReadOnlyMemory DotNext1() - { - scoped BufferWriterSlim writer = new BufferWriterSlim(); - - writer.Interpolate($"CONNECT {Host}:{Host} HTTP/1.1\r\n"); - writer.Interpolate($"Host: {Host}:{Host}\r\n"); - //if (false) - { - MemoryOwner token = Encoding.UTF8.GetBytes($"{User}:{Pass}".AsSpan(), s_allocator); - - int len = (int)(((uint)token.Length + 2) / 3 * 4); - writer.Write(line1); - - Base64Encoder encoder = new Base64Encoder(); - - encoder.EncodeToUtf16(token.Span, ref writer, true); - - writer.Write(newLine); - - - encoder.Reset(); - token.Dispose(); - } +[MemoryDiagnoser] +public class WriteConnectCommand +{ + public static string Host = "example.com"; + public static int Port = 25565; + public static string User = "asdnjakgvauyedgfuysgdjfhbsdghfgsdv"; + public static string Pass = "asdklasdkjadhfkjsdhgiuhskfgjdnhsduhfisbfd"; - writer.Write(newLine); - try - { - return Encoding.UTF8.GetBytes(writer.WrittenSpan, s_allocator).Memory; - } - finally - { - writer.Dispose(); - } - } + [Benchmark] + public byte[] StringBuilderApproach() + { + var sb = new StringBuilder(256); + sb.Append("CONNECT ").Append(Host).Append(':').Append(Port) + .Append(" HTTP/1.1\r\nHost: ").Append(Host).Append(':').Append(Port).Append("\r\n"); + byte[] credBytes = Encoding.UTF8.GetBytes($"{User}:{Pass}"); + sb.Append("Proxy-Authorization: Basic ").Append(Convert.ToBase64String(credBytes)).Append("\r\n"); + sb.Append("\r\n"); + return Encoding.UTF8.GetBytes(sb.ToString()); } -} \ No newline at end of file +} diff --git a/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj b/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj index 7df8e98..993a578 100644 --- a/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj +++ b/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj @@ -13,10 +13,7 @@ false - - - - - + + - \ No newline at end of file + diff --git a/QuickProxyNet.Tests/InternalTest.cs b/QuickProxyNet.Tests/InternalTest.cs index 28128ee..ea10e02 100644 --- a/QuickProxyNet.Tests/InternalTest.cs +++ b/QuickProxyNet.Tests/InternalTest.cs @@ -1,6 +1,4 @@ using System.Text; -using DotNext.Buffers; -using Span = DotNext.Span; namespace QuickProxyNet.Tests; @@ -29,7 +27,7 @@ public void ParseHttp1_0() Assert.True(b); - Assert.Equal(sb.ToString(), parser.GetString()); + Assert.Equal(sb.ToString(), parser.ToString()); } finally { @@ -60,7 +58,7 @@ public void ParseHttp1_1() Assert.True(b); - Assert.Equal(sb.ToString(), parser.GetString()); + Assert.Equal(sb.ToString(), parser.ToString()); } finally { @@ -91,7 +89,7 @@ public void ParseOneNewLine() Assert.False(b); - Assert.Equal(sb.ToString(), parser.GetString()); + Assert.Equal(sb.ToString(), parser.ToString()); } finally { @@ -122,7 +120,7 @@ public void ParseSegments(int segmentLength) writtenBytes.CopyTo(memory.Span); bool b = parser.Parse(writtenBytes.Length); - string gg = parser.GetString(); + string gg = parser.ToString(); bytes = bytes.Slice(length); @@ -130,7 +128,7 @@ public void ParseSegments(int segmentLength) break; } - bool isValid = parser.Validate(); + bool isValid = parser.GetStatusCode() == 200; Assert.True(isValid); Assert.Equal(sb.ToString(), parser.ToString()); diff --git a/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj b/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj index a9ff31c..994fd6a 100644 --- a/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj +++ b/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj @@ -10,10 +10,10 @@ - - - - + + + + diff --git a/QuickProxyNet.sln b/QuickProxyNet.sln deleted file mode 100644 index b786a90..0000000 --- a/QuickProxyNet.sln +++ /dev/null @@ -1,47 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.8.34219.65 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QuickProxyNet", "QuickProxyNet\QuickProxyNet.csproj", "{01090D42-F93E-4F2B-A78B-0319BA4A2368}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "build\_build.csproj", "{394D72C9-79F6-46F0-9612-3A8DA6FCFDA2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickProxyNet.Tests", "QuickProxyNet.Tests\QuickProxyNet.Tests.csproj", "{702FF47B-DD00-4472-AF06-81949B639BA9}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample", "Sample\Sample.csproj", "{801BEBAC-C593-42CA-91A1-CA2B5C9EB191}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickProxyNet.Benchmarks", "QuickProxyNet.Benchmarks\QuickProxyNet.Benchmarks.csproj", "{D98A6F7D-ED2C-4160-93C9-890FDF240AFB}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {394D72C9-79F6-46F0-9612-3A8DA6FCFDA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {394D72C9-79F6-46F0-9612-3A8DA6FCFDA2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {01090D42-F93E-4F2B-A78B-0319BA4A2368}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {01090D42-F93E-4F2B-A78B-0319BA4A2368}.Debug|Any CPU.Build.0 = Debug|Any CPU - {01090D42-F93E-4F2B-A78B-0319BA4A2368}.Release|Any CPU.ActiveCfg = Release|Any CPU - {01090D42-F93E-4F2B-A78B-0319BA4A2368}.Release|Any CPU.Build.0 = Release|Any CPU - {702FF47B-DD00-4472-AF06-81949B639BA9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {702FF47B-DD00-4472-AF06-81949B639BA9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {702FF47B-DD00-4472-AF06-81949B639BA9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {702FF47B-DD00-4472-AF06-81949B639BA9}.Release|Any CPU.Build.0 = Release|Any CPU - {801BEBAC-C593-42CA-91A1-CA2B5C9EB191}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {801BEBAC-C593-42CA-91A1-CA2B5C9EB191}.Debug|Any CPU.Build.0 = Debug|Any CPU - {801BEBAC-C593-42CA-91A1-CA2B5C9EB191}.Release|Any CPU.ActiveCfg = Release|Any CPU - {801BEBAC-C593-42CA-91A1-CA2B5C9EB191}.Release|Any CPU.Build.0 = Release|Any CPU - {D98A6F7D-ED2C-4160-93C9-890FDF240AFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D98A6F7D-ED2C-4160-93C9-890FDF240AFB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D98A6F7D-ED2C-4160-93C9-890FDF240AFB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D98A6F7D-ED2C-4160-93C9-890FDF240AFB}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {37455277-74B1-4F4B-A120-0BD87F8CA056} - EndGlobalSection -EndGlobal diff --git a/QuickProxyNet.slnx b/QuickProxyNet.slnx new file mode 100644 index 0000000..ebba7b1 --- /dev/null +++ b/QuickProxyNet.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/QuickProxyNet/Clients/HttpsProxyClient.cs b/QuickProxyNet/Clients/HttpsProxyClient.cs index edbac17..236d204 100644 --- a/QuickProxyNet/Clients/HttpsProxyClient.cs +++ b/QuickProxyNet/Clients/HttpsProxyClient.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Security; using System.Runtime.CompilerServices; using System.Security.Authentication; @@ -12,8 +12,6 @@ namespace QuickProxyNet; /// public class HttpsProxyClient : ProxyClient { - private SslCertificateValidationInfo sslValidationInfo; - public HttpsProxyClient(string host, int port) : base("https", host, port) { } @@ -23,126 +21,52 @@ public HttpsProxyClient(string host, int port, NetworkCredential credentials) : { } - public RemoteCertificateValidationCallback ServerCertificateValidationCallback { get; set; } + public RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; set; } public bool CheckCertificateRevocation { get; set; } - public X509CertificateCollection ClientCertificates { get; set; } + public X509CertificateCollection? ClientCertificates { get; set; } - public CipherSuitesPolicy SslCipherSuitesPolicy { get; set; } + public CipherSuitesPolicy? SslCipherSuitesPolicy { get; set; } public SslProtocols SslProtocols { get; set; } = SslProtocols.Tls12 | SslProtocols.Tls13; public override ProxyType Type => ProxyType.Https; - private SslClientAuthenticationOptions GetSslClientAuthenticationOptions(string host, - RemoteCertificateValidationCallback remoteCertificateValidationCallback) + private SslClientAuthenticationOptions GetSslClientAuthenticationOptions(string host) { return new SslClientAuthenticationOptions { CertificateRevocationCheckMode = CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, ApplicationProtocols = new List { SslApplicationProtocol.Http11 }, - RemoteCertificateValidationCallback = remoteCertificateValidationCallback, - + RemoteCertificateValidationCallback = ServerCertificateValidationCallback ?? DefaultValidation, CipherSuitesPolicy = SslCipherSuitesPolicy, - ClientCertificates = ClientCertificates, EnabledSslProtocols = SslProtocols, TargetHost = host }; } + private static bool DefaultValidation(object sender, X509Certificate? certificate, X509Chain? chain, + SslPolicyErrors sslPolicyErrors) => sslPolicyErrors == SslPolicyErrors.None; + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { - var ssl = new SslStream(stream, false, ValidateRemoteCertificate); + var ssl = new SslStream(stream, false); try { - await ssl.AuthenticateAsClientAsync(GetSslClientAuthenticationOptions(host, ValidateRemoteCertificate), - cancellationToken); + await ssl.AuthenticateAsClientAsync(GetSslClientAuthenticationOptions(host), cancellationToken); } - catch (Exception e) + catch { ssl.Dispose(); throw; } - var result = - await HttpHelper.EstablishHttpTunnelAsync(ssl, ProxyUri, host, port, ProxyCredentials, cancellationToken); - return result; - } - - private bool ValidateRemoteCertificate(object sender, X509Certificate certificate, X509Chain chain, - SslPolicyErrors sslPolicyErrors) - { - bool valid; - - sslValidationInfo?.Dispose(); - sslValidationInfo = null; - - if (ServerCertificateValidationCallback != null) - valid = ServerCertificateValidationCallback(ProxyHost, certificate, chain, sslPolicyErrors); - else if (ServicePointManager.ServerCertificateValidationCallback != null) - valid = ServicePointManager.ServerCertificateValidationCallback(ProxyHost, certificate, chain, - sslPolicyErrors); - else - valid = sslPolicyErrors == SslPolicyErrors.None; - - if (!valid) - // Note: The SslHandshakeException.Create() method will nullify this once it's done using it. - sslValidationInfo = new SslCertificateValidationInfo(sender, certificate, chain, sslPolicyErrors); - - return valid; - } - - private sealed class SslChainElement : IDisposable - { - public readonly X509Certificate2 Certificate; - public readonly X509ChainStatus[] ChainElementStatus; - public readonly string Information; - - public SslChainElement(X509ChainElement element) - { - Certificate = new X509Certificate2(element.Certificate.RawData); - ChainElementStatus = element.ChainElementStatus; - Information = element.Information; - } - - public void Dispose() - { - Certificate.Dispose(); - } - } - - private sealed class SslCertificateValidationInfo : IDisposable - { - public readonly X509Certificate2 Certificate; - public readonly List ChainElements; - public readonly X509ChainStatus[] ChainStatus; - public readonly string Host; - public readonly SslPolicyErrors SslPolicyErrors; - - public SslCertificateValidationInfo(object sender, X509Certificate certificate, X509Chain chain, - SslPolicyErrors sslPolicyErrors) - { - Certificate = new X509Certificate2(certificate.Export(X509ContentType.Cert)); - ChainElements = new List(); - SslPolicyErrors = sslPolicyErrors; - ChainStatus = chain.ChainStatus; - Host = sender as string; - - // Note: we need to copy the ChainElements because the chain will be destroyed - foreach (var element in chain.ChainElements) - ChainElements.Add(new SslChainElement(element)); - } - - public void Dispose() - { - Certificate.Dispose(); - foreach (var element in ChainElements) - element.Dispose(); - } + return await HttpHelper.EstablishHttpTunnelAsync(ssl, ProxyUri, host, port, ProxyCredentials, + cancellationToken); } -} \ No newline at end of file +} diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index a437819..0e1d134 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -96,10 +96,11 @@ private Socket CreateSocket() var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) { NoDelay = this.NoDelay, - LingerState = this.LingerState, SendTimeout = this.WriteTimeout, ReceiveTimeout = this.ReadTimeout }; + if (LingerState is not null) + socket.LingerState = LingerState; if (LocalEndPoint is not null) socket.Bind(LocalEndPoint); return socket; @@ -147,7 +148,7 @@ public virtual async ValueTask ConnectAsync(string host, int port, TimeS cancellationToken.ThrowIfCancellationRequested(); var socket = CreateSocket(); - await using var reg = cancellationToken.Register(s => ((IDisposable)s).Dispose(), socket); + await using var reg = cancellationToken.Register(static s => ((IDisposable?)s)?.Dispose(), socket); await using ITimer timer = TimeProvider.System.CreateTimer(OnDisposeSocket, socket, timeout, Timeout.InfiniteTimeSpan); diff --git a/QuickProxyNet/Ext.cs b/QuickProxyNet/Ext.cs deleted file mode 100644 index 100be63..0000000 --- a/QuickProxyNet/Ext.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.IO; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; - -namespace QuickProxyNet; - -public static class Ext -{ - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] - public static async ValueTask ReadToEndAsync(this Stream stream, Memory buffer, int length, - CancellationToken token) - { - var totalRead = 0; - while (totalRead < length) - { - var read = await stream.ReadAsync(buffer.Slice(totalRead), token); - if (read <= 0) - throw new EndOfStreamException(); - - totalRead += read; - } - - return totalRead; - } -} \ No newline at end of file diff --git a/QuickProxyNet/Internal/CancellationHelper.cs b/QuickProxyNet/Internal/CancellationHelper.cs deleted file mode 100644 index 312a475..0000000 --- a/QuickProxyNet/Internal/CancellationHelper.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace QuickProxyNet; - -internal static class CancellationHelper -{ - /// The default message used by . - private static readonly string - s_cancellationMessage = new OperationCanceledException().Message; // use same message as the default ctor - - /// Determines whether to wrap an in a cancellation exception. - /// The exception. - /// The that may have triggered the exception. - /// true if the exception should be wrapped; otherwise, false. - internal static bool ShouldWrapInOperationCanceledException(Exception exception, - CancellationToken cancellationToken) - { - return !(exception is OperationCanceledException) && cancellationToken.IsCancellationRequested; - } - - /// Creates a cancellation exception. - /// The inner exception to wrap. May be null. - /// The that triggered the cancellation. - /// The cancellation exception. - internal static Exception CreateOperationCanceledException(Exception? innerException, - CancellationToken cancellationToken) - { - return new TaskCanceledException(s_cancellationMessage, innerException, cancellationToken); - // TCE for compatibility with other handlers that use TaskCompletionSource.TrySetCanceled() - } - - /// Throws a cancellation exception. - /// The inner exception to wrap. May be null. - /// The that triggered the cancellation. - private static void ThrowOperationCanceledException(Exception? innerException, CancellationToken cancellationToken) - { - throw CreateOperationCanceledException(innerException, cancellationToken); - } - - /// Throws a cancellation exception if cancellation has been requested via . - /// The token to check for a cancellation request. - internal static void ThrowIfCancellationRequested(CancellationToken cancellationToken) - { - ThrowIfCancellationRequested(null, cancellationToken); - } - - /// Throws a cancellation exception if cancellation has been requested via . - /// The inner exception to wrap. May be null. - /// The token to check for a cancellation request. - internal static void ThrowIfCancellationRequested(Exception? innerException, CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - ThrowOperationCanceledException(innerException, cancellationToken); - } -} \ No newline at end of file diff --git a/QuickProxyNet/Internal/ConnectHelper.cs b/QuickProxyNet/Internal/ConnectHelper.cs deleted file mode 100644 index aaf57fb..0000000 --- a/QuickProxyNet/Internal/ConnectHelper.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Diagnostics; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; - -namespace QuickProxyNet; - -internal static class ConnectHelper -{ - private static SslClientAuthenticationOptions SetUpRemoteCertificateValidationCallback( - SslClientAuthenticationOptions sslOptions, HttpRequestMessage request) - { - // If there's a cert validation callback, and if it came from HttpClientHandler, - // wrap the original delegate in order to change the sender to be the request message (expected by HttpClientHandler's delegate). - var callback = sslOptions.RemoteCertificateValidationCallback; - if (callback != null && callback.Target is CertificateCallbackMapper mapper) - { - sslOptions = - sslOptions.ShallowClone(); // Clone as we're about to mutate it and don't want to affect the cached copy - var localFromHttpClientHandler = mapper.FromHttpClientHandler; - var localRequest = request; - sslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => - { - Debug.Assert(localRequest != null); - var result = localFromHttpClientHandler(localRequest, certificate as X509Certificate2, chain, - sslPolicyErrors); - localRequest = - null!; // ensure the SslOptions and this callback don't keep the first HttpRequestMessage alive indefinitely - return result; - }; - } - - return sslOptions; - } - - public static async ValueTask EstablishSslConnectionAsync(SslClientAuthenticationOptions sslOptions, - HttpRequestMessage request, bool async, Stream stream, CancellationToken cancellationToken) - { - sslOptions = SetUpRemoteCertificateValidationCallback(sslOptions, request); - - var sslStream = new SslStream(stream); - - try - { - if (async) - await sslStream.AuthenticateAsClientAsync(sslOptions, cancellationToken).ConfigureAwait(false); - else - using (cancellationToken.UnsafeRegister(static s => ((Stream)s!).Dispose(), stream)) - { - sslStream.AuthenticateAsClient(sslOptions); - } - } - catch (Exception e) - { - sslStream.Dispose(); - - if (e is OperationCanceledException) throw; - - if (CancellationHelper.ShouldWrapInOperationCanceledException(e, cancellationToken)) - throw CancellationHelper.CreateOperationCanceledException(e, cancellationToken); - - //HttpRequestException ex = new HttpRequestException(HttpRequestError.SecureConnectionError, SR.net_http_ssl_connection_failed, e); - - throw; - } - - // Handle race condition if cancellation happens after SSL auth completes but before the registration is disposed - if (cancellationToken.IsCancellationRequested) - { - sslStream.Dispose(); - throw CancellationHelper.CreateOperationCanceledException(null, cancellationToken); - } - - return sslStream; - } - - /// - /// Helper type used by HttpClientHandler when wrapping SocketsHttpHandler to map its - /// certificate validation callback to the one used by SslStream. - /// - internal sealed class CertificateCallbackMapper - { - public readonly RemoteCertificateValidationCallback ForSocketsHttpHandler; - - public readonly Func - FromHttpClientHandler; - - public CertificateCallbackMapper( - Func fromHttpClientHandler) - { - FromHttpClientHandler = fromHttpClientHandler; - ForSocketsHttpHandler = (sender, certificate, chain, sslPolicyErrors) => - FromHttpClientHandler((HttpRequestMessage)sender, certificate as X509Certificate2, chain, - sslPolicyErrors); - } - } - - //[SupportedOSPlatform("windows")] - //[SupportedOSPlatform("linux")] - //[SupportedOSPlatform("macos")] - //public static async ValueTask ConnectQuicAsync(HttpRequestMessage request, DnsEndPoint endPoint, TimeSpan idleTimeout, SslClientAuthenticationOptions clientAuthenticationOptions, CancellationToken cancellationToken) - //{ - // clientAuthenticationOptions = SetUpRemoteCertificateValidationCallback(clientAuthenticationOptions, request); - - // try - // { - // return await QuicConnection.ConnectAsync(new QuicClientConnectionOptions() - // { - // MaxInboundBidirectionalStreams = 0, // Client doesn't support inbound streams: https://www.rfc-editor.org/rfc/rfc9114.html#name-bidirectional-streams. An extension might change this. - // MaxInboundUnidirectionalStreams = 5, // Minimum is 3: https://www.rfc-editor.org/rfc/rfc9114.html#unidirectional-streams (1x control stream + 2x QPACK). Set to 100 if/when support for PUSH streams is added. - // IdleTimeout = idleTimeout, - // DefaultStreamErrorCode = (long)Http3ErrorCode.RequestCancelled, - // DefaultCloseErrorCode = (long)Http3ErrorCode.NoError, - // RemoteEndPoint = endPoint, - // ClientAuthenticationOptions = clientAuthenticationOptions - // }, cancellationToken).ConfigureAwait(false); - // } - // catch (Exception ex) when (ex is not OperationCanceledException) - // { - // throw; - // throw CreateWrappedException(ex, endPoint.Host, endPoint.Port, cancellationToken); - // } - //} -} \ No newline at end of file diff --git a/QuickProxyNet/Internal/Http3ErrorCode.cs b/QuickProxyNet/Internal/Http3ErrorCode.cs deleted file mode 100644 index 711050a..0000000 --- a/QuickProxyNet/Internal/Http3ErrorCode.cs +++ /dev/null @@ -1,108 +0,0 @@ -namespace QuickProxyNet; - -internal enum Http3ErrorCode : long -{ - /// - /// H3_NO_ERROR (0x100): - /// No error. This is used when the connection or stream needs to be closed, but there is no error to signal. - /// - NoError = 0x100, - - /// - /// H3_GENERAL_PROTOCOL_ERROR (0x101): - /// Peer violated protocol requirements in a way which doesn't match a more specific error code, - /// or endpoint declines to use the more specific error code. - /// - ProtocolError = 0x101, - - /// - /// H3_INTERNAL_ERROR (0x102): - /// An internal error has occurred in the HTTP stack. - /// - InternalError = 0x102, - - /// - /// H3_STREAM_CREATION_ERROR (0x103): - /// The endpoint detected that its peer created a stream that it will not accept. - /// - StreamCreationError = 0x103, - - /// - /// H3_CLOSED_CRITICAL_STREAM (0x104): - /// A stream required by the connection was closed or reset. - /// - ClosedCriticalStream = 0x104, - - /// - /// H3_FRAME_UNEXPECTED (0x105): - /// A frame was received which was not permitted in the current state. - /// - UnexpectedFrame = 0x105, - - /// - /// H3_FRAME_ERROR (0x106): - /// A frame that fails to satisfy layout requirements or with an invalid size was received. - /// - FrameError = 0x106, - - /// - /// H3_EXCESSIVE_LOAD (0x107): - /// The endpoint detected that its peer is exhibiting a behavior that might be generating excessive load. - /// - ExcessiveLoad = 0x107, - - /// - /// H3_ID_ERROR (0x109): - /// A Stream ID, Push ID, or Placeholder ID was used incorrectly, such as exceeding a limit, reducing a limit, or being - /// reused. - /// - IdError = 0x108, - - /// - /// H3_SETTINGS_ERROR (0x109): - /// An endpoint detected an error in the payload of a SETTINGS frame. - /// - SettingsError = 0x109, - - /// - /// H3_MISSING_SETTINGS (0x10A): - /// No SETTINGS frame was received at the beginning of the control stream. - /// - MissingSettings = 0x10a, - - /// - /// H3_REQUEST_REJECTED (0x10B): - /// A server rejected a request without performing any application processing. - /// - RequestRejected = 0x10b, - - /// - /// H3_REQUEST_CANCELLED (0x10C): - /// The request or its response (including pushed response) is cancelled. - /// - RequestCancelled = 0x10c, - - /// - /// H3_REQUEST_INCOMPLETE (0x10D): - /// The client's stream terminated without containing a fully-formed request. - /// - RequestIncomplete = 0x10d, - - /// - /// H3_MESSAGE_ERROR (0x10E): - /// An HTTP message was malformed and cannot be processed. - /// - MessageError = 0x10e, - - /// - /// H3_CONNECT_ERROR (0x10F): - /// The connection established in response to a CONNECT request was reset or abnormally closed. - /// - ConnectError = 0x10f, - - /// - /// H3_VERSION_FALLBACK (0x110): - /// The requested operation cannot be served over HTTP/3. The peer should retry over HTTP/1.1. - /// - VersionFallback = 0x110 -} \ No newline at end of file diff --git a/QuickProxyNet/Internal/HttpHelper.cs b/QuickProxyNet/Internal/HttpHelper.cs index 40052da..165051f 100644 --- a/QuickProxyNet/Internal/HttpHelper.cs +++ b/QuickProxyNet/Internal/HttpHelper.cs @@ -1,101 +1,140 @@ -using System.Buffers; -using System.Globalization; +using System.Buffers; using System.Net; -using System.Net.Security; using System.Runtime.CompilerServices; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; using System.Text; -using Cysharp.Text; -using DotNext; -using DotNext.Buffers; -using DotNext.Text; namespace QuickProxyNet; internal static class HttpHelper { - private static readonly char[] line1 = "Proxy-Authorization: Basic ".ToCharArray(); - private static readonly char[] newLine = "\r\n".ToCharArray(); + // Sync: builds the CONNECT command bytes. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static byte[] BuildConnectionCommand(string host, int port, NetworkCredential? credentials) + { + var sb = new StringBuilder(256); + sb.Append("CONNECT ").Append(host).Append(':').Append(port) + .Append(" HTTP/1.1\r\nHost: ").Append(host).Append(':').Append(port).Append("\r\n"); - private static MemoryAllocator s_allocator = ArrayPool.Shared.ToAllocator(); - private static MemoryAllocator s_allocator_char = ArrayPool.Shared.ToAllocator(); + if (credentials is not null) + { + byte[] credBytes = Encoding.UTF8.GetBytes($"{credentials.UserName}:{credentials.Password}"); + sb.Append("Proxy-Authorization: Basic ") + .Append(Convert.ToBase64String(credBytes)) + .Append("\r\n"); + } + + sb.Append("\r\n"); + return Encoding.UTF8.GetBytes(sb.ToString()); + } - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] - private static async ValueTask WriteConnectionCommand(Stream stream, string host, int port, - NetworkCredential? proxyCredentials, CancellationToken cancellationToken) + internal static async ValueTask EstablishHttpTunnelAsync(Stream stream, Uri proxyUri, string host, + int port, NetworkCredential? credentials, CancellationToken cancellationToken) { - var builder = ZString.CreateUtf8StringBuilder(); + byte[] cmd = BuildConnectionCommand(host, port, credentials); + await stream.WriteAsync(cmd.AsMemory(), cancellationToken); + + var parser = new HttpResponseParser(); try { - builder.AppendFormat("CONNECT {0}:{1} HTTP/1.1\r\n", host, port); - builder.AppendFormat("Host: {0}:{1}\r\n", host, port); + bool found; + do + { + var memory = parser.GetMemory(); + int nread = await stream.ReadAsync(memory, cancellationToken); + if (nread <= 0) + throw new EndOfStreamException("Proxy closed connection unexpectedly."); + found = parser.Parse(nread); + } while (!found); - if (proxyCredentials is not null) + int statusCode = parser.GetStatusCode(); + switch (statusCode) { - MemoryOwner token = - Encoding.UTF8.GetBytes($"{proxyCredentials.UserName}:{proxyCredentials.Password}".AsSpan(), - s_allocator); - - int len = (int)(((uint)token.Length + 2) / 3 * 4); - - MemoryOwner chars = s_allocator_char.AllocateExactly(len); - Convert.TryToBase64Chars(token.Span, chars.Span, out int written); - chars.TryResize(written); - builder.Append(line1.AsSpan()); - builder.Append(chars.Span); - builder.Append("\r\n"); - chars.Dispose(); - token.Dispose(); + case 200: + if (parser.HasOverreadBytes) + { + byte[] overread = parser.OverreadBytes.ToArray(); + return new PrefixedStream(overread, stream); + } + return stream; + case 407: + throw new ProxyProtocolException( + $"Proxy authentication required (407) for {host}:{port}."); + case -1: + throw new ProxyProtocolException("Proxy returned an invalid HTTP response."); + default: + throw new ProxyProtocolException( + $"Proxy CONNECT failed with HTTP {statusCode} for {host}:{port}."); } - - builder.Append("\r\n"); - - - await stream.WriteAsync(builder.AsMemory(), cancellationToken); } finally { - builder.Dispose(); + parser.Dispose(); } } - - internal static async ValueTask EstablishHttpTunnelAsync(Stream stream, Uri proxyUri, string host, int port, - NetworkCredential? credentials, CancellationToken cancellationToken) + private sealed class PrefixedStream(byte[] prefix, Stream inner) : Stream { - await WriteConnectionCommand(stream, host, port, credentials, cancellationToken); + private int _offset; + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => inner.CanWrite; + public override long Length => throw new NotSupportedException(); - var parser = new HttpResponseParser(); - try + public override long Position { - bool find; - do - { - var memory = parser.GetMemory(); - var nread = await stream.ReadAsync(memory, cancellationToken); - if (nread <= 0) - throw new EndOfStreamException(); - find = parser.Parse(nread); - } while (find == false); - - bool isValid = parser.Validate(); -#if DEBUG - string response = parser.ToString(); -#endif + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + public override void Flush() => inner.Flush(); + public override Task FlushAsync(CancellationToken ct) => inner.FlushAsync(ct); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); - if (!isValid) + public override int Read(Span buffer) + { + if (_offset < prefix.Length) { - throw new ProxyProtocolException($"Failed to connect {host}:{port}"); + int count = Math.Min(buffer.Length, prefix.Length - _offset); + prefix.AsSpan(_offset, count).CopyTo(buffer); + _offset += count; + return count; } + return inner.Read(buffer); + } + + public override int Read(byte[] buffer, int offset, int count) => + Read(buffer.AsSpan(offset, count)); - return stream; + public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) + { + if (_offset < prefix.Length) + { + int count = Math.Min(buffer.Length, prefix.Length - _offset); + prefix.AsMemory(_offset, count).CopyTo(buffer); + _offset += count; + return count; + } + return await inner.ReadAsync(buffer, ct); } - finally + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + ReadAsync(buffer.AsMemory(offset, count), ct).AsTask(); + + public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); + public override void Write(ReadOnlySpan buffer) => inner.Write(buffer); + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) => + inner.WriteAsync(buffer, ct); + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + inner.WriteAsync(buffer, offset, count, ct); + + protected override void Dispose(bool disposing) { - parser.Dispose(); + if (disposing) inner.Dispose(); + base.Dispose(disposing); } + + public override ValueTask DisposeAsync() => inner.DisposeAsync(); } -} \ No newline at end of file +} diff --git a/QuickProxyNet/Internal/HttpResponseParser.cs b/QuickProxyNet/Internal/HttpResponseParser.cs index 208dd68..cc100bd 100644 --- a/QuickProxyNet/Internal/HttpResponseParser.cs +++ b/QuickProxyNet/Internal/HttpResponseParser.cs @@ -1,105 +1,111 @@ -using System.Buffers; +using System.Buffers; using System.Runtime.CompilerServices; using System.Text; -using DotNext.Buffers; namespace QuickProxyNet; -internal struct HttpResponseParser +internal struct HttpResponseParser : IDisposable { - private static int BufferSize = 1024; - private MemoryOwner _memory; - private static readonly MemoryAllocator _allocator = ArrayPool.Shared.ToAllocator(); - private static readonly byte[] http1_1_200 = "HTTP/1.1 200".Select(x => (byte)x).ToArray(); - private static readonly byte[] http1_0_200 = "HTTP/1.0 200".Select(x => (byte)x).ToArray(); - private int _writtenCount = 0; - private int _indexEnd = -1; - public ReadOnlySpan Span => _memory.Span.Slice(0, _writtenCount); - - internal string GetString() - { - return Encoding.UTF8.GetString(Span); - } + private const int BufferSize = 1024; + + private byte[] _buffer; + private int _writtenCount; + private int _indexEnd; // absolute index of '\r\n\r\n' start, or -1 + + public ReadOnlySpan Span => _buffer.AsSpan(0, _writtenCount); + public HttpResponseParser() { - _memory = _allocator.AllocateExactly(BufferSize); + _buffer = ArrayPool.Shared.Rent(BufferSize); + _writtenCount = 0; + _indexEnd = -1; } public Memory GetMemory() { - if (_writtenCount < _memory.Length) - { - return _memory.Memory.Slice(_writtenCount); - } - - _memory.Resize(_writtenCount + BufferSize); - return _memory.Memory.Slice(_writtenCount); + if (_writtenCount < _buffer.Length) + return _buffer.AsMemory(_writtenCount); + + // Grow: rent a larger buffer, copy, return old + byte[] next = ArrayPool.Shared.Rent(_writtenCount + BufferSize); + _buffer.AsSpan(0, _writtenCount).CopyTo(next); + ArrayPool.Shared.Return(_buffer); + _buffer = next; + return _buffer.AsMemory(_writtenCount); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool Parse(int count) { _writtenCount += count; - return ParseHttpEnd(count); + return FindEndOfHeaders(count); } - private static readonly byte[] NewLine = "\r\n\r\n".Select(x => (byte)x).ToArray(); + private static readonly byte[] s_endOfHeaders = "\r\n\r\n"u8.ToArray(); [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool ParseHttpEnd(int count) + private bool FindEndOfHeaders(int newBytes) { - int start = _writtenCount - count; - int length = count; - - int offset = Math.Min(4, start); - start -= offset; - length += offset; - - ReadOnlySpan bytes = _memory.Span.Slice(start, length); + // Search the new bytes plus up to 3 preceding bytes (to catch \r\n\r\n split across reads) + int start = _writtenCount - newBytes; + int lookback = Math.Min(3, start); + start -= lookback; + int length = newBytes + lookback; - int index = bytes.IndexOf(NewLine); + int index = _buffer.AsSpan(start, length).IndexOf(s_endOfHeaders); if (index < 0) - { return false; - } _indexEnd = index + start; - return true; } - public bool Validate() + /// Returns HTTP status code (e.g. 200, 407), or -1 if response is malformed. + public int GetStatusCode() { - if (_indexEnd == -1) - { - throw new InvalidOperationException("No find http response"); - } - ReadOnlySpan span = Span; + // Minimum: "HTTP/1.x NNN" = 12 bytes + if (span.Length < 12) return -1; + if (!span.StartsWith("HTTP/1."u8)) return -1; - if (span.Length > (uint)_indexEnd + 4) - { - return false; - } + // Status code occupies bytes 9..11 + ReadOnlySpan code = span.Slice(9, 3); + if (code[0] < (byte)'0' || code[0] > (byte)'9') return -1; + if (code[1] < (byte)'0' || code[1] > (byte)'9') return -1; + if (code[2] < (byte)'0' || code[2] > (byte)'9') return -1; - if (span.Length >= 15 && - (span.StartsWith(http1_0_200) || span.StartsWith(http1_1_200))) + return (code[0] - '0') * 100 + (code[1] - '0') * 10 + (code[2] - '0'); + } + + /// + /// True if bytes were read beyond the end of the HTTP response headers. + /// These bytes belong to the tunneled connection and must be re-prepended to the stream. + /// + public bool HasOverreadBytes => _indexEnd >= 0 && _writtenCount > _indexEnd + 4; + + /// Returns the bytes read beyond the end of HTTP response headers. + public ReadOnlySpan OverreadBytes + { + get { - return true; + if (!HasOverreadBytes) return ReadOnlySpan.Empty; + int start = _indexEnd + 4; + return _buffer.AsSpan(start, _writtenCount - start); } - - return false; } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public override string ToString() - { - return Encoding.UTF8.GetString(_memory.Span.Slice(0, _indexEnd + 4)); - } + public override string ToString() => + _indexEnd >= 0 + ? Encoding.UTF8.GetString(_buffer, 0, _indexEnd + 4) + : Encoding.UTF8.GetString(_buffer, 0, _writtenCount); public void Dispose() { - _memory.Dispose(); + byte[] buf = _buffer; + _buffer = null!; + if (buf is not null) + ArrayPool.Shared.Return(buf); } -} \ No newline at end of file +} diff --git a/QuickProxyNet/Internal/RequestRetryType.cs b/QuickProxyNet/Internal/RequestRetryType.cs deleted file mode 100644 index 1d17cde..0000000 --- a/QuickProxyNet/Internal/RequestRetryType.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace QuickProxyNet; - -internal enum RequestRetryType -{ - /// - /// The request must not be retried; this indicates we aren't certain the server hasn't processed the request. - /// - NoRetry, - - /// - /// The request failed due to e.g. server shutting down (GOAWAY) and should be retried on a new connection. - /// - RetryOnConnectionFailure, - - /// - /// The request failed on the current HTTP version, and the server requested it be retried on a lower version. - /// - RetryOnLowerHttpVersion, - - /// - /// The proxy failed, so the request should be retried on the next proxy. - /// - RetryOnNextProxy, - - /// - /// The HTTP/2 connection reached the maximum number of streams and - /// another HTTP/2 connection must be created or found to serve the request. - /// - RetryOnStreamLimitReached -} \ No newline at end of file diff --git a/QuickProxyNet/Internal/SslClientAuthenticationOptionsExtensions.cs b/QuickProxyNet/Internal/SslClientAuthenticationOptionsExtensions.cs deleted file mode 100644 index b5fe6c7..0000000 --- a/QuickProxyNet/Internal/SslClientAuthenticationOptionsExtensions.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Collections; -using System.Diagnostics; -using System.Net.Security; -using System.Reflection; - -namespace QuickProxyNet; - -internal static class SslClientAuthenticationOptionsExtensions -{ - public static SslClientAuthenticationOptions ShallowClone(this SslClientAuthenticationOptions options) - { - // Use non-default values to verify the clone works fine. - var clone = new SslClientAuthenticationOptions - { - AllowRenegotiation = options.AllowRenegotiation, - AllowTlsResume = options.AllowTlsResume, - ApplicationProtocols = options.ApplicationProtocols != null - ? new List(options.ApplicationProtocols) - : null, - CertificateRevocationCheckMode = options.CertificateRevocationCheckMode, - CertificateChainPolicy = options.CertificateChainPolicy, - CipherSuitesPolicy = options.CipherSuitesPolicy, - ClientCertificates = options.ClientCertificates, - ClientCertificateContext = options.ClientCertificateContext, - EnabledSslProtocols = options.EnabledSslProtocols, - EncryptionPolicy = options.EncryptionPolicy, - LocalCertificateSelectionCallback = options.LocalCertificateSelectionCallback, - RemoteCertificateValidationCallback = options.RemoteCertificateValidationCallback, - TargetHost = options.TargetHost - }; - -#if DEBUG - // Try to detect if a property gets added that we're not copying correctly. - // The property count is guard for new properties that also needs to be added above. - var properties = - typeof(SslClientAuthenticationOptions).GetProperties(BindingFlags.Public | BindingFlags.Instance | - BindingFlags.DeclaredOnly)!; - Debug.Assert(properties.Length == 13); - foreach (var pi in properties) - { - var origValue = pi.GetValue(options); - var cloneValue = pi.GetValue(clone); - - if (origValue is IEnumerable origEnumerable) - { - var cloneEnumerable = cloneValue as IEnumerable; - Debug.Assert(cloneEnumerable != null, $"{pi.Name}. Expected enumerable cloned value."); - - var e1 = origEnumerable.GetEnumerator(); - try - { - var e2 = cloneEnumerable.GetEnumerator(); - try - { - while (e1.MoveNext()) - { - Debug.Assert(e2.MoveNext(), $"{pi.Name}. Cloned enumerator too short."); - Debug.Assert(Equals(e1.Current, e2.Current), - $"{pi.Name}. Cloned enumerator's values don't match."); - } - - Debug.Assert(!e2.MoveNext(), $"{pi.Name}. Cloned enumerator too long."); - } - finally - { - (e2 as IDisposable)?.Dispose(); - } - } - finally - { - (e1 as IDisposable)?.Dispose(); - } - } - else - { - Debug.Assert(Equals(origValue, cloneValue), $"{pi.Name}. Expected: {origValue}, Actual: {cloneValue}"); - } - } -#endif - - return clone; - } -} \ No newline at end of file diff --git a/QuickProxyNet/QuickProxyNet.csproj b/QuickProxyNet/QuickProxyNet.csproj index e236ceb..7f7a200 100644 --- a/QuickProxyNet/QuickProxyNet.csproj +++ b/QuickProxyNet/QuickProxyNet.csproj @@ -32,8 +32,6 @@ - - diff --git a/QuickProxyNet/SocketHelper.cs b/QuickProxyNet/SocketHelper.cs deleted file mode 100644 index bc45e1d..0000000 --- a/QuickProxyNet/SocketHelper.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace QuickProxyNet; - -public static class SocketHelper -{ -} \ No newline at end of file From 9f87b34f3d03c92d6650aae8b60ba71085c2bd6e Mon Sep 17 00:00:00 2001 From: TitleHHHH Date: Sat, 7 Mar 2026 00:06:37 +0500 Subject: [PATCH 2/5] perf: zero-alloc HTTP CONNECT builder; remove AggressiveInlining noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpHelper.BuildConnectionCommand: replace StringBuilder with direct ArrayPool writes — eliminates 4 allocations per connection (StringBuilder, sb.ToString(), credBytes string, Convert.ToBase64String). Now uses Utf8Formatter.TryFormat for port, Base64.EncodeToUtf8 for credentials, Encoding.UTF8.GetBytes(span) for host. Remove [MethodImpl(AggressiveInlining|AggressiveOptimization)] from all async and virtual methods — JIT ignores these attributes on state machines and virtual dispatch. Remove now-unused System.Runtime.CompilerServices usings from client files. Remove SocksHelper.WriteAsync single-line wrapper; inline stream.WriteAsync at call sites directly. Co-Authored-By: Claude Sonnet 4.6 --- QuickProxyNet/Clients/HttpProxyClient.cs | 2 - QuickProxyNet/Clients/HttpsProxyClient.cs | 2 - QuickProxyNet/Clients/ProxyClient.cs | 2 - QuickProxyNet/Clients/Socks4Client.cs | 2 - QuickProxyNet/Clients/Socks4aClient.cs | 2 - QuickProxyNet/Internal/HttpHelper.cs | 91 ++++++++++++++++---- QuickProxyNet/Internal/HttpResponseParser.cs | 7 +- QuickProxyNet/Internal/SocksHelper.cs | 15 ++-- 8 files changed, 84 insertions(+), 39 deletions(-) diff --git a/QuickProxyNet/Clients/HttpProxyClient.cs b/QuickProxyNet/Clients/HttpProxyClient.cs index 3afa184..dbd0aac 100644 --- a/QuickProxyNet/Clients/HttpProxyClient.cs +++ b/QuickProxyNet/Clients/HttpProxyClient.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Runtime.CompilerServices; namespace QuickProxyNet; @@ -19,7 +18,6 @@ public HttpProxyClient(string host, int port, NetworkCredential credentials) : b public override ProxyType Type => ProxyType.Http; - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { diff --git a/QuickProxyNet/Clients/HttpsProxyClient.cs b/QuickProxyNet/Clients/HttpsProxyClient.cs index 236d204..2f94960 100644 --- a/QuickProxyNet/Clients/HttpsProxyClient.cs +++ b/QuickProxyNet/Clients/HttpsProxyClient.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Security; -using System.Runtime.CompilerServices; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; @@ -51,7 +50,6 @@ private SslClientAuthenticationOptions GetSslClientAuthenticationOptions(string private static bool DefaultValidation(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) => sslPolicyErrors == SslPolicyErrors.None; - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index 0e1d134..5cd9019 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Sockets; -using System.Runtime.CompilerServices; namespace QuickProxyNet; @@ -90,7 +89,6 @@ private static void OnDisposeSocket(object? state) } } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private Socket CreateSocket() { var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) diff --git a/QuickProxyNet/Clients/Socks4Client.cs b/QuickProxyNet/Clients/Socks4Client.cs index 5813d15..8be048f 100644 --- a/QuickProxyNet/Clients/Socks4Client.cs +++ b/QuickProxyNet/Clients/Socks4Client.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Runtime.CompilerServices; namespace QuickProxyNet; @@ -21,7 +20,6 @@ public Socks4Client(string host, int port, NetworkCredential credentials) : base public override ProxyType Type => ProxyType.Socks4; - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { diff --git a/QuickProxyNet/Clients/Socks4aClient.cs b/QuickProxyNet/Clients/Socks4aClient.cs index a293e23..2f3f7d3 100644 --- a/QuickProxyNet/Clients/Socks4aClient.cs +++ b/QuickProxyNet/Clients/Socks4aClient.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Runtime.CompilerServices; namespace QuickProxyNet; @@ -23,7 +22,6 @@ public Socks4aClient(string host, int port, NetworkCredential credentials) : bas public override ProxyType Type => ProxyType.Socks4a; - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { diff --git a/QuickProxyNet/Internal/HttpHelper.cs b/QuickProxyNet/Internal/HttpHelper.cs index 165051f..d81dcd7 100644 --- a/QuickProxyNet/Internal/HttpHelper.cs +++ b/QuickProxyNet/Internal/HttpHelper.cs @@ -1,37 +1,98 @@ using System.Buffers; +using System.Buffers.Text; using System.Net; -using System.Runtime.CompilerServices; using System.Text; namespace QuickProxyNet; internal static class HttpHelper { - // Sync: builds the CONNECT command bytes. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static byte[] BuildConnectionCommand(string host, int port, NetworkCredential? credentials) + // Static byte literals — no allocation, shared across calls + private static ReadOnlySpan S_connect => "CONNECT "u8; + private static ReadOnlySpan S_http11Host => " HTTP/1.1\r\nHost: "u8; + private static ReadOnlySpan S_proxyAuth => "Proxy-Authorization: Basic "u8; + private static ReadOnlySpan S_crlf => "\r\n"u8; + + // Builds CONNECT command into a rented ArrayPool buffer. + // Caller must return the buffer via ArrayPool.Shared.Return(). + private static (byte[] buffer, int length) BuildConnectionCommand( + string host, int port, NetworkCredential? credentials) { - var sb = new StringBuilder(256); - sb.Append("CONNECT ").Append(host).Append(':').Append(port) - .Append(" HTTP/1.1\r\nHost: ").Append(host).Append(':').Append(port).Append("\r\n"); + int hostMaxBytes = Encoding.UTF8.GetMaxByteCount(host.Length); + + // CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n + // 8 255 1 5 17 255 1 5 2 2 = ~551 bytes worst case + int size = 8 + hostMaxBytes + 1 + 5 + 17 + hostMaxBytes + 1 + 5 + 4; if (credentials is not null) { - byte[] credBytes = Encoding.UTF8.GetBytes($"{credentials.UserName}:{credentials.Password}"); - sb.Append("Proxy-Authorization: Basic ") - .Append(Convert.ToBase64String(credBytes)) - .Append("\r\n"); + int credMaxBytes = Encoding.UTF8.GetMaxByteCount(credentials.UserName.Length) + + 1 + + Encoding.UTF8.GetMaxByteCount(credentials.Password.Length); + // "Proxy-Authorization: Basic " (27) + base64(cred) + "\r\n" (2) + size += 27 + (credMaxBytes + 2) / 3 * 4 + 2; + } + + byte[] buf = ArrayPool.Shared.Rent(size); + int pos = 0; + + // CONNECT {host}:{port} HTTP/1.1\r\n + S_connect.CopyTo(buf.AsSpan(pos)); pos += S_connect.Length; + pos += Encoding.UTF8.GetBytes(host, buf.AsSpan(pos)); + buf[pos++] = (byte)':'; + Utf8Formatter.TryFormat(port, buf.AsSpan(pos), out int portLen); pos += portLen; + + // Host: {host}:{port}\r\n + S_http11Host.CopyTo(buf.AsSpan(pos)); pos += S_http11Host.Length; + pos += Encoding.UTF8.GetBytes(host, buf.AsSpan(pos)); + buf[pos++] = (byte)':'; + Utf8Formatter.TryFormat(port, buf.AsSpan(pos), out portLen); pos += portLen; + S_crlf.CopyTo(buf.AsSpan(pos)); pos += 2; + + if (credentials is not null) + { + // Proxy-Authorization: Basic {base64(user:pass)}\r\n + S_proxyAuth.CopyTo(buf.AsSpan(pos)); pos += S_proxyAuth.Length; + + int userLen = Encoding.UTF8.GetByteCount(credentials.UserName); + int passLen = Encoding.UTF8.GetByteCount(credentials.Password); + int credLen = userLen + 1 + passLen; + + byte[] credBuf = ArrayPool.Shared.Rent(credLen); + try + { + Encoding.UTF8.GetBytes(credentials.UserName, credBuf.AsSpan()); + credBuf[userLen] = (byte)':'; + Encoding.UTF8.GetBytes(credentials.Password, credBuf.AsSpan(userLen + 1)); + Base64.EncodeToUtf8(credBuf.AsSpan(0, credLen), buf.AsSpan(pos), out _, out int b64Len); + pos += b64Len; + } + finally + { + ArrayPool.Shared.Return(credBuf); + } + + S_crlf.CopyTo(buf.AsSpan(pos)); pos += 2; } - sb.Append("\r\n"); - return Encoding.UTF8.GetBytes(sb.ToString()); + // End of headers + S_crlf.CopyTo(buf.AsSpan(pos)); pos += 2; + + return (buf, pos); } internal static async ValueTask EstablishHttpTunnelAsync(Stream stream, Uri proxyUri, string host, int port, NetworkCredential? credentials, CancellationToken cancellationToken) { - byte[] cmd = BuildConnectionCommand(host, port, credentials); - await stream.WriteAsync(cmd.AsMemory(), cancellationToken); + var (cmd, cmdLen) = BuildConnectionCommand(host, port, credentials); + try + { + await stream.WriteAsync(cmd.AsMemory(0, cmdLen), cancellationToken); + } + finally + { + ArrayPool.Shared.Return(cmd); + } var parser = new HttpResponseParser(); try diff --git a/QuickProxyNet/Internal/HttpResponseParser.cs b/QuickProxyNet/Internal/HttpResponseParser.cs index cc100bd..5b6b9ad 100644 --- a/QuickProxyNet/Internal/HttpResponseParser.cs +++ b/QuickProxyNet/Internal/HttpResponseParser.cs @@ -1,5 +1,4 @@ using System.Buffers; -using System.Runtime.CompilerServices; using System.Text; namespace QuickProxyNet; @@ -34,7 +33,7 @@ public Memory GetMemory() return _buffer.AsMemory(_writtenCount); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Parse(int count) { _writtenCount += count; @@ -43,7 +42,7 @@ public bool Parse(int count) private static readonly byte[] s_endOfHeaders = "\r\n\r\n"u8.ToArray(); - [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool FindEndOfHeaders(int newBytes) { // Search the new bytes plus up to 3 preceding bytes (to catch \r\n\r\n split across reads) @@ -95,7 +94,7 @@ public ReadOnlySpan OverreadBytes } } - [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override string ToString() => _indexEnd >= 0 ? Encoding.UTF8.GetString(_buffer, 0, _indexEnd + 4) diff --git a/QuickProxyNet/Internal/SocksHelper.cs b/QuickProxyNet/Internal/SocksHelper.cs index 34df5c3..15e33ab 100644 --- a/QuickProxyNet/Internal/SocksHelper.cs +++ b/QuickProxyNet/Internal/SocksHelper.cs @@ -3,7 +3,6 @@ using System.Diagnostics; using System.Net; using System.Net.Sockets; -using System.Runtime.CompilerServices; using System.Text; namespace QuickProxyNet; @@ -52,7 +51,7 @@ internal static async ValueTask EstablishSocks5TunnelAsync(Stream stream, string buffer[3] = METHOD_USERNAME_PASSWORD; } - await WriteAsync(stream, buffer.AsMemory(0, buffer[1] + 2), cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(buffer.AsMemory(0, buffer[1] + 2), cancellationToken).ConfigureAwait(false); // +----+--------+ // |VER | METHOD | @@ -89,7 +88,7 @@ internal static async ValueTask EstablishSocks5TunnelAsync(Stream stream, string var passwordLength = EncodeString(credentials.Password, buffer.AsSpan(3 + usernameLength), nameof(credentials.Password)); buffer[2 + usernameLength] = passwordLength; - await WriteAsync(stream, buffer.AsMemory(0, 3 + usernameLength + passwordLength), cancellationToken) + await stream.WriteAsync(buffer.AsMemory(0, 3 + usernameLength + passwordLength), cancellationToken) .ConfigureAwait(false); // +----+--------+ @@ -146,7 +145,7 @@ await WriteAsync(stream, buffer.AsMemory(0, 3 + usernameLength + passwordLength) BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(addressLength + 4), (ushort)port); - await WriteAsync(stream, buffer.AsMemory(0, addressLength + 6), cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(buffer.AsMemory(0, addressLength + 6), cancellationToken).ConfigureAwait(false); // +----+-----+-------+------+----------+----------+ // |VER | REP | RSV | ATYP | DST.ADDR | DST.PORT | @@ -248,7 +247,7 @@ await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationTo totalLength += hostLength + 1; } - await WriteAsync(stream, buffer.AsMemory(0, totalLength), cancellationToken).ConfigureAwait(false); + await stream.WriteAsync(buffer.AsMemory(0, totalLength), cancellationToken).ConfigureAwait(false); // +----+----+----+----+----+----+----+----+ // | VN | CD | DSTPORT | DSTIP | @@ -296,9 +295,5 @@ private static void VerifyProtocolVersion(byte expected, byte version) $"Unexpected SOCKS protocol version. Required {expected}, got {version}."); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static ValueTask WriteAsync(Stream stream, Memory buffer, CancellationToken cancellationToken) - { - return stream.WriteAsync(buffer, cancellationToken); - } + } \ No newline at end of file From 92afda76bae09781e519d30f5ab957a10ede33e2 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Sat, 7 Mar 2026 22:23:08 +0500 Subject: [PATCH 3/5] feat: add static Proxy API, HTTPS in ProxyConnector, FindEndOfHeaders benchmarks - Add Proxy.ConnectAsync static methods (zero client allocation) for mass checkers - Add ProxyUriExtensions with ConnectThroughProxyAsync extension on Uri - Add HTTPS support to ProxyConnector (SslStream + default TLS options) - Fix ProxyClientFactory.Instance to be a cached singleton - Add FindEndOfHeadersBenchmark comparing IndexOf vs SearchValues approaches - Update benchmarks: net10.0 target, InProcess toolchain, BenchmarkSwitcher - Include CI/CD workflows, ProxyErrorCode enum, and prior refactoring changes Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yaml | 43 +++-- .github/workflows/publish.yaml | 35 ++++ CLAUDE.md | 39 +++- .../FindEndOfHeadersBenchmark.cs | 141 ++++++++++++++ QuickProxyNet.Benchmarks/Program.cs | 18 +- .../QuickProxyNet.Benchmarks.csproj | 6 +- QuickProxyNet/Internal/HttpHelper.cs | 7 +- QuickProxyNet/Internal/SocksHelper.cs | 24 +-- QuickProxyNet/Proxy.cs | 174 ++++++++++++++++++ QuickProxyNet/ProxyClientFactory.cs | 2 +- QuickProxyNet/ProxyConnector.cs | 28 ++- QuickProxyNet/ProxyProtocolException.cs | 59 ++++-- QuickProxyNet/QuickProxyNet.csproj | 5 +- build/Build.cs | 13 -- 14 files changed, 504 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/publish.yaml create mode 100644 QuickProxyNet.Benchmarks/FindEndOfHeadersBenchmark.cs create mode 100644 QuickProxyNet/Proxy.cs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index e739b47..22f23c6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,24 +1,29 @@ -name: release -on: - workflow_dispatch: - inputs: - NugetApiUrl: - description: API URL +name: Build & Test + +on: + push: + branches: [master] + pull_request: + branches: [master] jobs: - main: - runs-on: windows-latest - + build: + runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - uses: actions/checkout@v4 + - name: Setup .NET uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.x - - - - name: Push to NuGet - run: ./build.cmd Push --Confuguration Release --NugetApiUrl ${{github.event.inputs.NugetApiUrl}} --NugetApiKey ${{ secrets.NUGET_API_KEY }} --GithubApiKey ${{secrets.GITHUB_TOKEN}} \ No newline at end of file + with: + dotnet-version: | + 8.x + 9.x + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore -c Release + + - name: Test + run: dotnet test --no-build -c Release diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..8fa2847 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,35 @@ +name: Publish to NuGet + +on: + push: + tags: ["v*"] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 9.x + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore -c Release + + - name: Test + run: dotnet test --no-build -c Release + + - name: Pack + run: dotnet pack QuickProxyNet/QuickProxyNet.csproj --no-build -c Release -o artifacts + + - name: Push to NuGet + run: dotnet nuget push artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} --skip-duplicate diff --git a/CLAUDE.md b/CLAUDE.md index 679f87a..7938b54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,12 +55,13 @@ build/ — Nuke build automation | Package | Purpose | |---|---| -| `DotNext` 5.25.x | Advanced .NET utilities (buffers, memory, text) | -| `DotNext.IO` 5.25.x | I/O pipeline utilities, `SpanWriter` | -| `ZString` 2.6.0 | Zero-alloc string building (Cysharp) | -| `ConfigureAwait.Fody` | IL weaving — ConfigureAwait on all awaits | +| `MinVer` 6.0.0 | Versioning from git tags (no config needed) | +| `ConfigureAwait.Fody` 3.3.2 | IL weaving — ConfigureAwait on all awaits | +| `DotNet.ReproducibleBuilds` 1.2.4 | Deterministic builds | | `System.IO.Pipelines` | Pipelines project only | +BCL-only for protocol logic — no external runtime dependencies. + ## Code Style & Patterns ### C# Settings (all projects) @@ -72,8 +73,8 @@ build/ — Nuke build automation - **`ArrayPool.Shared.Rent/Return`** for temporary buffers - **`stackalloc`** for small stack buffers (`stackalloc char[256]`) - **`ReadOnlySpan` / `Memory`** for buffer slices -- **`SpanWriter`** (from DotNext) for binary protocol writing -- **`[MethodImpl(AggressiveInlining | AggressiveOptimization)]`** on hot paths +- **`Utf8Formatter.TryFormat`** for int→UTF-8 without alloc +- **`Base64.EncodeToUtf8`** for base64 directly to byte span - **`PreallocatedStream`** pattern to recycle response buffers without allocation - **`BinaryPrimitives.WriteUInt16BigEndian`** for big-endian network byte order @@ -105,7 +106,29 @@ Pack # Create NuGet package (Release mode) Push # Publish to NuGet / GitHub Packages ``` -Versioning: **GitVersion** 5.12.0 (git-based semantic versioning). +Versioning: **MinVer** 6.0.0 — version is derived from git tags automatically. + +## CI/CD (GitHub Actions) + +Two workflows in `.github/workflows/`: + +### `build.yaml` — Build & Test +- **Triggers:** push to `master`, PRs to `master` +- **Steps:** restore → build → test +- Runs on `ubuntu-latest` with .NET 8.x + 9.x + +### `publish.yaml` — Publish to NuGet +- **Triggers:** push tag `v*` (e.g. `v1.2.3`) +- **Steps:** restore → build → test → pack → push to nuget.org +- Uses `fetch-depth: 0` so MinVer can read tag history +- **Required secret:** `NUGET_API_KEY` (repo Settings → Secrets → Actions) + +### Release workflow +```bash +git tag v1.2.3 +git push origin v1.2.3 +# GitHub Actions automatically builds, tests, packs, and publishes to NuGet +``` ## Testing @@ -137,7 +160,7 @@ The `QuickProxyNet.Pipelines/` project rewrites the internals using `System.IO.P 3. **Always release `ArrayPool` rentals** in `finally` blocks. 4. **Public API must be XML-documented** — `GenerateDocumentationFile` is enabled. 5. **ConfigureAwait** is handled by Fody weaving — do not add manually. -6. **Multi-targeting** — changes in `QuickProxyNet/` must be compatible with net8, net9, net10. +6. **Multi-targeting** — changes in `QuickProxyNet/` must be compatible with net8.0, net9.0, and net10.0. 7. **`ProxyErrorCode`** — add new codes there before throwing new exception types. 8. When editing protocol logic, validate against the relevant RFC: - SOCKS4/4a: no official RFC, de-facto standard diff --git a/QuickProxyNet.Benchmarks/FindEndOfHeadersBenchmark.cs b/QuickProxyNet.Benchmarks/FindEndOfHeadersBenchmark.cs new file mode 100644 index 0000000..474ab89 --- /dev/null +++ b/QuickProxyNet.Benchmarks/FindEndOfHeadersBenchmark.cs @@ -0,0 +1,141 @@ +using System; +using System.Buffers; +using System.Text; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// Compares approaches for finding "\r\n\r\n" in HTTP response buffers. +/// Tests whether SearchValues<byte> provides benefit over plain IndexOf. +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class FindEndOfHeadersBenchmark +{ + private class Config : ManualConfig + { + public Config() + { + AddJob(Job.ShortRun.WithToolchain(InProcessNoEmitToolchain.Instance)); + } + } + // Typical HTTP CONNECT response: ~80 bytes headers, \r\n\r\n at the end + private byte[] _shortResponse = null!; + + // Large response with many headers: ~600 bytes, \r\n\r\n near the end + private byte[] _longResponse = null!; + + // Worst case: \r\n\r\n at the very end of a 4 KB buffer + private byte[] _worstCase = null!; + + private static readonly byte[] s_endOfHeaders = "\r\n\r\n"u8.ToArray(); + + private static readonly SearchValues s_crSearch = SearchValues.Create("\r"u8); + + [GlobalSetup] + public void Setup() + { + _shortResponse = Encoding.UTF8.GetBytes( + "HTTP/1.1 200 Connection established\r\n" + + "Proxy-Agent: nginx\r\n" + + "\r\n"); + + _longResponse = Encoding.UTF8.GetBytes( + "HTTP/1.1 200 Connection established\r\n" + + "Proxy-Agent: squid/4.15\r\n" + + "X-Cache: MISS from proxy.example.com\r\n" + + "X-Cache-Lookup: NONE from proxy.example.com:3128\r\n" + + "Via: 1.1 proxy.example.com (squid/4.15)\r\n" + + "Connection: keep-alive\r\n" + + "Date: Thu, 01 Jan 2026 00:00:00 GMT\r\n" + + "X-Forwarded-For: 192.168.1.100\r\n" + + "X-Request-Id: abcdef01-2345-6789-abcd-ef0123456789\r\n" + + "Content-Length: 0\r\n" + + "\r\n"); + + // 4 KB buffer with \r\n\r\n at the very end + _worstCase = new byte[4096]; + // Fill with plausible header-like content (no premature \r\n\r\n) + var padding = Encoding.UTF8.GetBytes("X-Header: value\r\n"); + int pos = 0; + while (pos + padding.Length + 4 <= _worstCase.Length) + { + padding.CopyTo(_worstCase, pos); + pos += padding.Length; + } + // Fill remaining with spaces, then put \r\n\r\n at the end + while (pos < _worstCase.Length - 4) _worstCase[pos++] = (byte)' '; + "\r\n\r\n"u8.CopyTo(_worstCase.AsSpan(pos)); + } + + // === Approach 1: Current — IndexOf with static byte[] === + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Short")] + public int IndexOf_StaticArray_Short() => _shortResponse.AsSpan().IndexOf(s_endOfHeaders); + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Long")] + public int IndexOf_StaticArray_Long() => _longResponse.AsSpan().IndexOf(s_endOfHeaders); + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Worst")] + public int IndexOf_StaticArray_Worst() => _worstCase.AsSpan().IndexOf(s_endOfHeaders); + + // === Approach 2: IndexOf with u8 literal directly === + + [Benchmark] + [BenchmarkCategory("Short")] + public int IndexOf_Utf8Literal_Short() => _shortResponse.AsSpan().IndexOf("\r\n\r\n"u8); + + [Benchmark] + [BenchmarkCategory("Long")] + public int IndexOf_Utf8Literal_Long() => _longResponse.AsSpan().IndexOf("\r\n\r\n"u8); + + [Benchmark] + [BenchmarkCategory("Worst")] + public int IndexOf_Utf8Literal_Worst() => _worstCase.AsSpan().IndexOf("\r\n\r\n"u8); + + // === Approach 3: SearchValues-assisted (find \r, then validate sequence) === + + [Benchmark] + [BenchmarkCategory("Short")] + public int SearchValues_Short() => FindWithSearchValues(_shortResponse); + + [Benchmark] + [BenchmarkCategory("Long")] + public int SearchValues_Long() => FindWithSearchValues(_longResponse); + + [Benchmark] + [BenchmarkCategory("Worst")] + public int SearchValues_Worst() => FindWithSearchValues(_worstCase); + + private static int FindWithSearchValues(ReadOnlySpan span) + { + int offset = 0; + while (offset <= span.Length - 4) + { + int idx = span[offset..].IndexOfAny(s_crSearch); + if (idx < 0) return -1; + + int abs = offset + idx; + if (abs + 3 < span.Length && + span[abs + 1] == (byte)'\n' && + span[abs + 2] == (byte)'\r' && + span[abs + 3] == (byte)'\n') + { + return abs; + } + + offset = abs + 1; + } + + return -1; + } +} diff --git a/QuickProxyNet.Benchmarks/Program.cs b/QuickProxyNet.Benchmarks/Program.cs index 000ed2b..bacde38 100644 --- a/QuickProxyNet.Benchmarks/Program.cs +++ b/QuickProxyNet.Benchmarks/Program.cs @@ -1,17 +1,11 @@ -using BenchmarkDotNet.Configs; using BenchmarkDotNet.Running; -namespace QuickProxyNet.Benchmarks +namespace QuickProxyNet.Benchmarks; + +public class Program { - public class Program + public static void Main(string[] args) { - public static void Main(string[] args) - { - var config = DefaultConfig.Instance; - var summary = BenchmarkRunner.Run(config, args); - - // Use this to select benchmarks from the console: - // var summaries = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); - } + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); } -} \ No newline at end of file +} diff --git a/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj b/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj index 993a578..56c57e0 100644 --- a/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj +++ b/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj @@ -1,6 +1,6 @@ - net8.0 + net10.0 Exe @@ -14,6 +14,8 @@ - + + + diff --git a/QuickProxyNet/Internal/HttpHelper.cs b/QuickProxyNet/Internal/HttpHelper.cs index d81dcd7..59bef7a 100644 --- a/QuickProxyNet/Internal/HttpHelper.cs +++ b/QuickProxyNet/Internal/HttpHelper.cs @@ -118,12 +118,13 @@ internal static async ValueTask EstablishHttpTunnelAsync(Stream stream, } return stream; case 407: - throw new ProxyProtocolException( + throw new ProxyProtocolException(ProxyErrorCode.AuthRequired, $"Proxy authentication required (407) for {host}:{port}."); case -1: - throw new ProxyProtocolException("Proxy returned an invalid HTTP response."); + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + "Proxy returned an invalid HTTP response."); default: - throw new ProxyProtocolException( + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, $"Proxy CONNECT failed with HTTP {statusCode} for {host}:{port}."); } } diff --git a/QuickProxyNet/Internal/SocksHelper.cs b/QuickProxyNet/Internal/SocksHelper.cs index 15e33ab..605e3d9 100644 --- a/QuickProxyNet/Internal/SocksHelper.cs +++ b/QuickProxyNet/Internal/SocksHelper.cs @@ -74,7 +74,7 @@ internal static async ValueTask EstablishSocks5TunnelAsync(Stream stream, string // If the server is behaving well, it shouldn't pick username and password auth // because we don't claim to support it when we don't have credentials. // Just being defensive here. - throw new ProxyProtocolException("SOCKS server requested username & password authentication."); + throw new ProxyProtocolException(ProxyErrorCode.AuthRequired, "SOCKS server requested username & password authentication."); // +----+------+----------+------+----------+ // |VER | ULEN | UNAME | PLEN | PASSWD | @@ -98,12 +98,12 @@ await stream.WriteAsync(buffer.AsMemory(0, 3 + usernameLength + passwordLength), // +----+--------+ await stream.ReadExactlyAsync(buffer.AsMemory(0, 2), cancellationToken).ConfigureAwait(false); if (buffer[0] != SubnegotiationVersion || buffer[1] != Socks5_Success) - throw new ProxyProtocolException("Failed to authenticate with the SOCKS server."); + throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, "Failed to authenticate with the SOCKS server."); break; } default: - throw new ProxyProtocolException("SOCKS server did not return a suitable authentication method."); + throw new ProxyProtocolException(ProxyErrorCode.SocksNoAuthMethod, "SOCKS server did not return a suitable authentication method."); } @@ -155,13 +155,13 @@ await stream.WriteAsync(buffer.AsMemory(0, 3 + usernameLength + passwordLength), await stream.ReadExactlyAsync(buffer.AsMemory(0, 5), cancellationToken).ConfigureAwait(false); VerifyProtocolVersion(ProtocolVersion5, buffer[0]); if (buffer[1] != Socks5_Success) - throw new ProxyProtocolException("SOCKS server failed to connect to the destination."); + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, "SOCKS server failed to connect to the destination."); var bytesToSkip = buffer[3] switch { ATYP_IPV4 => 5, ATYP_IPV6 => 17, ATYP_DOMAIN_NAME => buffer[4] + 2, - _ => throw new ProxyProtocolException("SOCKS server returned an unknown address type.") + _ => throw new ProxyProtocolException(ProxyErrorCode.SocksBadAddressType, "SOCKS server returned an unknown address type.") }; await stream.ReadExactlyAsync(buffer.AsMemory(0, bytesToSkip), cancellationToken).ConfigureAwait(false); // response address not used @@ -198,7 +198,7 @@ internal static async ValueTask EstablishSocks4TunnelAsync(Stream stream, bool i else if (hostIP.IsIPv4MappedToIPv6) ipv4Address = hostIP.MapToIPv4(); else - throw new ProxyProtocolException("SOCKS4 does not support IPv6 addresses."); + throw new ProxyProtocolException(ProxyErrorCode.SocksIPv6NotSupported, "SOCKS4 does not support IPv6 addresses."); } else if (!isVersion4a) { @@ -212,11 +212,11 @@ await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationTo } catch (Exception ex) { - throw new ProxyProtocolException("Failed to resolve the destination host to an IPv4 address.s", ex); + throw new ProxyProtocolException(ProxyErrorCode.SocksNoIPv4Address, "Failed to resolve the destination host to an IPv4 address.", ex); } if (addresses.Length == 0) - throw new ProxyProtocolException("Failed to resolve the destination host to an IPv4 address.s"); + throw new ProxyProtocolException(ProxyErrorCode.SocksNoIPv4Address, "Failed to resolve the destination host to an IPv4 address."); ipv4Address = addresses[0]; } @@ -263,9 +263,9 @@ await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationTo // Nothing to do break; case Socks4_AuthFailed: - throw new ProxyProtocolException("Failed to authenticate with the SOCKS server."); + throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, "Failed to authenticate with the SOCKS server."); default: - throw new ProxyProtocolException("SOCKS server failed to connect to the destination."); + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, "SOCKS server failed to connect to the destination."); } // response address not used } @@ -284,14 +284,14 @@ private static byte EncodeString(ReadOnlySpan chars, Span buffer, st catch { Debug.Assert(Encoding.UTF8.GetByteCount(chars) > 255); - throw new ProxyProtocolException($"Encoding the {parameterName} took more than the maximum of 255 bytes"); + throw new ProxyProtocolException(ProxyErrorCode.SocksStringTooLong, $"Encoding the {parameterName} took more than the maximum of 255 bytes"); } } private static void VerifyProtocolVersion(byte expected, byte version) { if (expected != version) - throw new ProxyProtocolException( + throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, $"Unexpected SOCKS protocol version. Required {expected}, got {version}."); } diff --git a/QuickProxyNet/Proxy.cs b/QuickProxyNet/Proxy.cs new file mode 100644 index 0000000..aef9e09 --- /dev/null +++ b/QuickProxyNet/Proxy.cs @@ -0,0 +1,174 @@ +using System.Net; +using System.Net.Sockets; + +namespace QuickProxyNet; + +/// +/// Provides static convenience methods for connecting through a proxy in a single call. +/// No intermediate is allocated — the socket and tunnel +/// negotiation happen inline, making this ideal for mass proxy checking. +/// +/// +/// +/// await using var stream = await Proxy.ConnectAsync( +/// new Uri("socks5://user:pass@127.0.0.1:1080"), +/// "example.com", 443); +/// +/// +public static class Proxy +{ + /// + /// Connects to a target host through the specified proxy. + /// Opens a socket, negotiates the tunnel, and returns the connected stream. + /// The caller owns the returned and must dispose it. + /// + /// + /// Proxy URI including scheme, host, port, and optional credentials. + /// Supported schemes: http, https, socks4, socks4a, socks5. + /// + /// The target host to connect to through the proxy. + /// The target port. + /// A token to cancel the operation. + /// A connected tunneled through the proxy. + public static ValueTask ConnectAsync(Uri proxyUri, string host, int port, + CancellationToken cancellationToken = default) + { + return ConnectCoreAsync(proxyUri, host, port, timeout: null, cancellationToken); + } + + /// + /// Connects to a target host through the specified proxy with a timeout. + /// + /// + /// Proxy URI including scheme, host, port, and optional credentials. + /// Supported schemes: http, https, socks4, socks4a, socks5. + /// + /// The target host to connect to through the proxy. + /// The target port. + /// Maximum time to wait for the connection to complete. + /// A token to cancel the operation. + /// A connected tunneled through the proxy. + public static ValueTask ConnectAsync(Uri proxyUri, string host, int port, + TimeSpan timeout, CancellationToken cancellationToken = default) + { + return ConnectCoreAsync(proxyUri, host, port, timeout, cancellationToken); + } + + /// + /// Negotiates a proxy tunnel over an existing stream (e.g. for proxy chaining). + /// No socket is created — the caller provides an already-connected stream to the proxy. + /// + /// + /// Proxy URI including scheme and optional credentials. + /// Supported schemes: http, https, socks4, socks4a, socks5. + /// + /// An already-connected stream to the proxy server. + /// The target host to connect to through the proxy. + /// The target port. + /// A token to cancel the operation. + /// A connected tunneled through the proxy. + public static ValueTask ConnectAsync(Uri proxyUri, Stream source, string host, int port, + CancellationToken cancellationToken = default) + { + var credentials = ParseCredentials(proxyUri); + return ProxyConnector.ConnectToProxyAsync(source, proxyUri, host, port, credentials, cancellationToken); + } + + private static async ValueTask ConnectCoreAsync(Uri proxyUri, string host, int port, + TimeSpan? timeout, CancellationToken cancellationToken) + { + ProxyClient.ValidateArguments(host, port); + cancellationToken.ThrowIfCancellationRequested(); + + var credentials = ParseCredentials(proxyUri); + + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) + { + NoDelay = true, + LingerState = new LingerOption(true, 0) + }; + + ITimer? timer = null; + if (timeout.HasValue) + timer = TimeProvider.System.CreateTimer(static s => ((Socket)s!).Dispose(), socket, + timeout.Value, Timeout.InfiniteTimeSpan); + + try + { + await socket.ConnectAsync(proxyUri.Host, proxyUri.Port, cancellationToken); + } + catch + { + if (timer is not null) await timer.DisposeAsync(); + socket.Dispose(); + throw; + } + + var stream = new NetworkStream(socket, ownsSocket: true); + try + { + var result = await ProxyConnector.ConnectToProxyAsync(stream, proxyUri, host, port, credentials, + cancellationToken); + return result; + } + catch + { + if (timer is not null) await timer.DisposeAsync(); + await stream.DisposeAsync(); + throw; + } + finally + { + if (timer is not null) await timer.DisposeAsync(); + } + } + + private static NetworkCredential? ParseCredentials(Uri proxyUri) + { + if (string.IsNullOrEmpty(proxyUri.UserInfo)) + return null; + + var sep = proxyUri.UserInfo.IndexOf(':'); + if (sep < 0) + return new NetworkCredential(proxyUri.UserInfo, string.Empty); + + return new NetworkCredential( + proxyUri.UserInfo.Substring(0, sep), + proxyUri.UserInfo.Substring(sep + 1)); + } +} + +/// +/// Extension methods for connecting through proxies via . +/// +public static class ProxyUriExtensions +{ + /// + /// Connects to a target host through the proxy specified by this URI. + /// + /// The proxy URI (scheme://[user:pass@]host:port). + /// The target host. + /// The target port. + /// A token to cancel the operation. + /// A connected tunneled through the proxy. + public static ValueTask ConnectThroughProxyAsync(this Uri proxyUri, string host, int port, + CancellationToken cancellationToken = default) + { + return Proxy.ConnectAsync(proxyUri, host, port, cancellationToken); + } + + /// + /// Connects to a target host through the proxy specified by this URI, with a timeout. + /// + /// The proxy URI (scheme://[user:pass@]host:port). + /// The target host. + /// The target port. + /// Maximum time to wait for the connection. + /// A token to cancel the operation. + /// A connected tunneled through the proxy. + public static ValueTask ConnectThroughProxyAsync(this Uri proxyUri, string host, int port, + TimeSpan timeout, CancellationToken cancellationToken = default) + { + return Proxy.ConnectAsync(proxyUri, host, port, timeout, cancellationToken); + } +} diff --git a/QuickProxyNet/ProxyClientFactory.cs b/QuickProxyNet/ProxyClientFactory.cs index b4e9fd5..e0f2c2e 100644 --- a/QuickProxyNet/ProxyClientFactory.cs +++ b/QuickProxyNet/ProxyClientFactory.cs @@ -11,7 +11,7 @@ public sealed class ProxyClientFactory /// /// Gets a singleton instance of the ProxyClientFactory. /// - public static ProxyClientFactory Instance => new(); + public static ProxyClientFactory Instance { get; } = new(); /// /// Creates an IProxyClient instance based on the provided URI, automatically determining the proxy type diff --git a/QuickProxyNet/ProxyConnector.cs b/QuickProxyNet/ProxyConnector.cs index 6df6e18..9b17613 100644 --- a/QuickProxyNet/ProxyConnector.cs +++ b/QuickProxyNet/ProxyConnector.cs @@ -1,4 +1,7 @@ using System.Net; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; namespace QuickProxyNet; @@ -43,7 +46,24 @@ await SocksHelper return result; } - throw new NotSupportedException("Bad protocol"); + if (string.Equals(proxyUri.Scheme, "https", StringComparison.OrdinalIgnoreCase)) + { + var ssl = new SslStream(stream, false); + try + { + await ssl.AuthenticateAsClientAsync(DefaultSslOptions(proxyUri.Host), cancellationToken); + } + catch + { + ssl.Dispose(); + throw; + } + + return await HttpHelper.EstablishHttpTunnelAsync(ssl, proxyUri, host, port, credentials, + cancellationToken); + } + + throw new NotSupportedException($"Unsupported proxy scheme: {proxyUri.Scheme}"); } catch { @@ -52,4 +72,10 @@ await SocksHelper } } } + + private static SslClientAuthenticationOptions DefaultSslOptions(string targetHost) => new() + { + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + TargetHost = targetHost + }; } \ No newline at end of file diff --git a/QuickProxyNet/ProxyProtocolException.cs b/QuickProxyNet/ProxyProtocolException.cs index b98d3fb..b745723 100644 --- a/QuickProxyNet/ProxyProtocolException.cs +++ b/QuickProxyNet/ProxyProtocolException.cs @@ -1,31 +1,54 @@ -//#if SERIALIZABLE -//using System.Security; -//using System.Runtime.Serialization; -//#endif - namespace QuickProxyNet; -//#if SERIALIZABLE -// [Serializable] -//#endif +/// +/// Represents an error that occurred during proxy protocol negotiation. +/// public class ProxyProtocolException : Exception { - //#if SERIALIZABLE - // [SecuritySafeCritical] - // protected ProxyProtocolException (SerializationInfo info, StreamingContext context) : base (info, context) - // { - // } - //#endif + /// + /// Gets the specific error code describing the failure reason. + /// + public ProxyErrorCode ErrorCode { get; } - public ProxyProtocolException(string message, Exception innerException) : base(message, innerException) + public ProxyProtocolException(ProxyErrorCode errorCode, string message, Exception innerException) : base(message, innerException) { + ErrorCode = errorCode; } - public ProxyProtocolException(string message) : base(message) + public ProxyProtocolException(ProxyErrorCode errorCode, string message) : base(message) { + ErrorCode = errorCode; } - public ProxyProtocolException() + public ProxyProtocolException(ProxyErrorCode errorCode) { + ErrorCode = errorCode; } -} \ No newline at end of file +} + +/// +/// Describes the specific reason a proxy protocol operation failed. +/// +public enum ProxyErrorCode +{ + /// The proxy server requires authentication. + AuthRequired, + /// Authentication with the proxy server failed. + AuthFailed, + /// The proxy server failed to connect to the destination. + ConnectionFailed, + /// The proxy returned an invalid or unparseable response. + InvalidResponse, + /// A SOCKS string field exceeded the 255-byte limit. + SocksStringTooLong, + /// The SOCKS server returned an unexpected protocol version. + SocksUnexpectedVersion, + /// The SOCKS server did not offer a suitable authentication method. + SocksNoAuthMethod, + /// The SOCKS server returned an unknown address type. + SocksBadAddressType, + /// SOCKS4 does not support IPv6 addresses. + SocksIPv6NotSupported, + /// Failed to resolve host to an IPv4 address (required for SOCKS4). + SocksNoIPv4Address +} diff --git a/QuickProxyNet/QuickProxyNet.csproj b/QuickProxyNet/QuickProxyNet.csproj index 7f7a200..c7f9a58 100644 --- a/QuickProxyNet/QuickProxyNet.csproj +++ b/QuickProxyNet/QuickProxyNet.csproj @@ -1,9 +1,11 @@  - net8.0;net9.0 + net8.0;net9.0;net10.0 enable enable latest + v + 3.0 QuickProxyNet @@ -32,6 +34,7 @@ + diff --git a/build/Build.cs b/build/Build.cs index a2b82ec..29fc886 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -7,7 +7,6 @@ using Nuke.Common.ProjectModel; using Nuke.Common.Tooling; using Nuke.Common.Tools.DotNet; -using Nuke.Common.Tools.GitVersion; using Nuke.Common.Utilities.Collections; using Serilog; using static Nuke.Common.EnvironmentInfo; @@ -31,7 +30,6 @@ class Build : NukeBuild Tool ValidationTool; [GitRepository] readonly GitRepository GitRepository; - [GitVersion] readonly GitVersion GitVersion; [Parameter] string NugetApiUrl = "https://api.nuget.org/v3/index.json"; [Parameter] string NugetApiKey; @@ -71,13 +69,6 @@ class Build : NukeBuild .SetProjectFile(Solution)); }); - Target PrintVersion => _ => _ - .Executes(() => - { - Log.Information(GitVersion.FullSemVer); - Log.Information(GitVersion.NuGetVersionV2); - }); - Target Compile => _ => _ .DependsOn(Restore) .Executes(() => @@ -85,9 +76,6 @@ class Build : NukeBuild DotNetBuild(_ => _ .SetProjectFile(Solution) .SetConfiguration(Configuration) - .SetAssemblyVersion(GitVersion.AssemblySemVer) - .SetFileVersion(GitVersion.AssemblySemFileVer) - .SetInformationalVersion(GitVersion.InformationalVersion) .EnableNoRestore()); }); @@ -112,7 +100,6 @@ class Build : NukeBuild DotNetPack(s => s .SetProject(Solution.QuickProxyNet) .SetConfiguration(Configuration) - .SetVersion(GitVersion.NuGetVersionV2) .SetNoDependencies(true) .SetContinuousIntegrationBuild(true) .SetOutputDirectory(ArtifactsDirectory / "nuget")); From 281d6146d166f507c3f27cbdcc481e7cce88b941 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Sun, 8 Mar 2026 18:02:39 +0500 Subject: [PATCH 4/5] fix: improve exception handling, fix resource leaks, harden protocol parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ProxyErrorCode.Timeout; detect timeouts via StrongBox+Volatile instead of letting ObjectDisposedException propagate untyped - Wrap SocketException in ProxyProtocolException with proxy+target context - Fix SslStream leak in ProxyConnector HTTPS path (auth+tunnel in one try) - Fix timer race in Proxy.ConnectCoreAsync (dispose before returning stream) - Replace EndOfStreamException with ProxyProtocolException in HttpHelper - Include SOCKS5 REP code in rejection error messages - Fix Split(':') → IndexOf+Substring for passwords containing colons - Add SOCKS4 reply VN=0 check, separate SOCKS5 auth VER/STATUS checks - Fix ProxyClient constructor null-check order (validate before new Uri) - Narrow EncodeString catch to ArgumentException - Add comprehensive tests for HttpResponseParser, HttpHelper, SOCKS5, PrefixedStream Co-Authored-By: Claude Opus 4.6 --- QuickProxyNet.Tests/ConnectTest.cs | 80 ++++++- .../Helpers/FakeProxyStream.cs | 50 +++++ QuickProxyNet.Tests/HttpHelperTest.cs | 115 ++++++++++ QuickProxyNet.Tests/InternalTest.cs | 205 +++++++++++++----- QuickProxyNet.Tests/PrefixedStreamTest.cs | 81 +++++++ .../QuickProxyNet.Tests.csproj | 2 +- QuickProxyNet.Tests/Socks5HelperTest.cs | 180 +++++++++++++++ QuickProxyNet/Clients/ProxyClient.cs | 61 +++--- QuickProxyNet/Internal/HttpHelper.cs | 3 +- QuickProxyNet/Internal/HttpResponseParser.cs | 10 + QuickProxyNet/Internal/SocksHelper.cs | 14 +- QuickProxyNet/Proxy.cs | 35 ++- QuickProxyNet/ProxyClientFactory.cs | 8 +- QuickProxyNet/ProxyConnector.cs | 9 +- QuickProxyNet/ProxyProtocolException.cs | 4 +- 15 files changed, 744 insertions(+), 113 deletions(-) create mode 100644 QuickProxyNet.Tests/Helpers/FakeProxyStream.cs create mode 100644 QuickProxyNet.Tests/HttpHelperTest.cs create mode 100644 QuickProxyNet.Tests/PrefixedStreamTest.cs create mode 100644 QuickProxyNet.Tests/Socks5HelperTest.cs diff --git a/QuickProxyNet.Tests/ConnectTest.cs b/QuickProxyNet.Tests/ConnectTest.cs index 59852d2..b0f7bfa 100644 --- a/QuickProxyNet.Tests/ConnectTest.cs +++ b/QuickProxyNet.Tests/ConnectTest.cs @@ -1,6 +1,80 @@ -namespace QuickProxyNet.Tests; +using System.Text; +namespace QuickProxyNet.Tests; + +/// +/// Integration tests against real proxies. +/// Set environment variables to run: +/// HTTP_PROXY_URI = http://[user:pass@]host:port +/// SOCKS5_PROXY_URI = socks5://[user:pass@]host:port +/// Tests are skipped if the variable is not set. +/// public class ConnectTest { - -} \ No newline at end of file + private const string TargetHost = "example.com"; + private const int TargetPort = 80; + + private static string? GetEnv(string name) => + Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v : null; + + [Fact] + public async Task HttpProxy_ConnectAndSendRequest() + { + var proxyUrl = GetEnv("HTTP_PROXY_URI"); + if (proxyUrl is null) return; // skip: "HTTP_PROXY_URI not set"); + + var uri = new Uri(proxyUrl); + await using var stream = await Proxy.ConnectAsync(uri, TargetHost, TargetPort, + TimeSpan.FromSeconds(10)); + + // Send a minimal HTTP GET and verify we get a response + var request = Encoding.UTF8.GetBytes($"GET / HTTP/1.1\r\nHost: {TargetHost}\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(request); + + var buf = new byte[1024]; + int read = await stream.ReadAsync(buf); + Assert.True(read > 0); + + var response = Encoding.UTF8.GetString(buf, 0, read); + Assert.StartsWith("HTTP/1.", response); + } + + [Fact] + public async Task Socks5Proxy_ConnectAndSendRequest() + { + var proxyUrl = GetEnv("SOCKS5_PROXY_URI"); + if (proxyUrl is null) return; // skip: "SOCKS5_PROXY_URI not set"); + + var uri = new Uri(proxyUrl); + await using var stream = await Proxy.ConnectAsync(uri, TargetHost, TargetPort, + TimeSpan.FromSeconds(10)); + + var request = Encoding.UTF8.GetBytes($"GET / HTTP/1.1\r\nHost: {TargetHost}\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(request); + + var buf = new byte[1024]; + int read = await stream.ReadAsync(buf); + Assert.True(read > 0); + + var response = Encoding.UTF8.GetString(buf, 0, read); + Assert.StartsWith("HTTP/1.", response); + } + + [Fact] + public async Task ExtensionMethod_ConnectThroughProxy() + { + var proxyUrl = GetEnv("HTTP_PROXY_URI") ?? GetEnv("SOCKS5_PROXY_URI"); + if (proxyUrl is null) return; // skip: "No proxy URI set"); + + var uri = new Uri(proxyUrl); + await using var stream = await uri.ConnectThroughProxyAsync(TargetHost, TargetPort, + TimeSpan.FromSeconds(10)); + + var request = Encoding.UTF8.GetBytes($"GET / HTTP/1.1\r\nHost: {TargetHost}\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(request); + + var buf = new byte[1024]; + int read = await stream.ReadAsync(buf); + Assert.True(read > 0); + } +} diff --git a/QuickProxyNet.Tests/Helpers/FakeProxyStream.cs b/QuickProxyNet.Tests/Helpers/FakeProxyStream.cs new file mode 100644 index 0000000..f0d0f48 --- /dev/null +++ b/QuickProxyNet.Tests/Helpers/FakeProxyStream.cs @@ -0,0 +1,50 @@ +namespace QuickProxyNet.Tests.Helpers; + +/// +/// A stream backed by a MemoryStream that supports scripted responses. +/// Write calls go into a sink; Read calls return pre-loaded response bytes. +/// +internal sealed class FakeProxyStream : Stream +{ + private readonly MemoryStream _response; + private readonly MemoryStream _written = new(); + + public FakeProxyStream(byte[] responseBytes) + { + _response = new MemoryStream(responseBytes); + } + + /// All bytes written by the client (the CONNECT command, etc.). + public byte[] WrittenBytes => _written.ToArray(); + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + 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 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) => + _response.Read(buffer, offset, count); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) => + _response.ReadAsync(buffer, ct); + + public override void Write(byte[] buffer, int offset, int count) => + _written.Write(buffer, offset, count); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) => + _written.WriteAsync(buffer, ct); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _response.Dispose(); + _written.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/QuickProxyNet.Tests/HttpHelperTest.cs b/QuickProxyNet.Tests/HttpHelperTest.cs new file mode 100644 index 0000000..9c63175 --- /dev/null +++ b/QuickProxyNet.Tests/HttpHelperTest.cs @@ -0,0 +1,115 @@ +using System.Text; +using QuickProxyNet.Tests.Helpers; + +namespace QuickProxyNet.Tests; + +public class HttpHelperTest +{ + private static readonly Uri ProxyUri = new("http://proxy.example.com:8080"); + + [Fact] + public async Task EstablishTunnel_200_ReturnsStream() + { + var response = Encoding.UTF8.GetBytes("HTTP/1.1 200 Connection established\r\n\r\n"); + var stream = new FakeProxyStream(response); + + var result = await HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "example.com", 443, null, + CancellationToken.None); + + // Should return the same stream (no overread) + Assert.Same(stream, result); + } + + [Fact] + public async Task EstablishTunnel_200_WithOverread_ReturnsPrefixedStream() + { + // Simulate proxy sending extra bytes after headers (overread) + var response = Encoding.UTF8.GetBytes("HTTP/1.1 200 Connection established\r\n\r\nHELLO"); + var stream = new FakeProxyStream(response); + + var result = await HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "example.com", 443, null, + CancellationToken.None); + + // Should NOT be the same stream — it's a PrefixedStream wrapping the overread bytes + Assert.NotSame(stream, result); + + // Read the overread bytes from the PrefixedStream + var buf = new byte[10]; + int read = await result.ReadAsync(buf); + Assert.Equal("HELLO", Encoding.UTF8.GetString(buf, 0, read)); + } + + [Fact] + public async Task EstablishTunnel_407_ThrowsAuthRequired() + { + var response = Encoding.UTF8.GetBytes( + "HTTP/1.1 407 Proxy Authentication Required\r\n" + + "Proxy-Authenticate: Basic realm=\"proxy\"\r\n\r\n"); + var stream = new FakeProxyStream(response); + + var ex = await Assert.ThrowsAsync( + () => HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "example.com", 443, null, + CancellationToken.None).AsTask()); + + Assert.Equal(ProxyErrorCode.AuthRequired, ex.ErrorCode); + } + + [Fact] + public async Task EstablishTunnel_403_ThrowsConnectionFailed() + { + var response = Encoding.UTF8.GetBytes("HTTP/1.1 403 Forbidden\r\n\r\n"); + var stream = new FakeProxyStream(response); + + var ex = await Assert.ThrowsAsync( + () => HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "example.com", 443, null, + CancellationToken.None).AsTask()); + + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + } + + [Fact] + public async Task EstablishTunnel_SendsCorrectCommand_NoAuth() + { + var response = Encoding.UTF8.GetBytes("HTTP/1.1 200 Connection established\r\n\r\n"); + var stream = new FakeProxyStream(response); + + await HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "target.com", 8080, null, + CancellationToken.None); + + var sent = Encoding.UTF8.GetString(stream.WrittenBytes); + Assert.Contains("CONNECT target.com:8080 HTTP/1.1\r\n", sent); + Assert.Contains("Host: target.com:8080\r\n", sent); + Assert.DoesNotContain("Proxy-Authorization", sent); + } + + [Fact] + public async Task EstablishTunnel_SendsCorrectCommand_WithAuth() + { + var response = Encoding.UTF8.GetBytes("HTTP/1.1 200 Connection established\r\n\r\n"); + var stream = new FakeProxyStream(response); + var creds = new System.Net.NetworkCredential("user", "pass"); + + await HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "target.com", 443, creds, + CancellationToken.None); + + var sent = Encoding.UTF8.GetString(stream.WrittenBytes); + Assert.Contains("CONNECT target.com:443 HTTP/1.1\r\n", sent); + Assert.Contains("Proxy-Authorization: Basic ", sent); + + // Verify Base64 of "user:pass" + var expected = Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass")); + Assert.Contains(expected, sent); + } + + [Fact] + public async Task EstablishTunnel_ProxyClosed_ThrowsProxyProtocolException() + { + // Empty response = proxy closed connection + var stream = new FakeProxyStream([]); + + var ex = await Assert.ThrowsAsync( + () => HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "example.com", 443, null, + CancellationToken.None).AsTask()); + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + } +} diff --git a/QuickProxyNet.Tests/InternalTest.cs b/QuickProxyNet.Tests/InternalTest.cs index ea10e02..5ad4dec 100644 --- a/QuickProxyNet.Tests/InternalTest.cs +++ b/QuickProxyNet.Tests/InternalTest.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; namespace QuickProxyNet.Tests; @@ -7,27 +7,19 @@ public class HttpResponseParserTest [Fact] public void ParseHttp1_0() { - StringBuilder sb = new StringBuilder(); - sb.AppendLine("HTTP/1.0 200 OK"); - sb.AppendLine(); - - Span bytes = Encoding.UTF8.GetBytes(sb.ToString()); - + // Use explicit \r\n (HTTP protocol line endings), NOT AppendLine which uses OS newline + var raw = "HTTP/1.0 200 OK\r\n\r\n"; + Span bytes = Encoding.UTF8.GetBytes(raw); HttpResponseParser parser = new HttpResponseParser(); try { Memory memory = parser.GetMemory(); - bytes.CopyTo(memory.Span); - - int writtenBytes = Math.Min(bytes.Length, memory.Length); - - bool b = parser.Parse(writtenBytes); + bool b = parser.Parse(bytes.Length); Assert.True(b); - - Assert.Equal(sb.ToString(), parser.ToString()); + Assert.Equal(raw, parser.ToString()); } finally { @@ -38,27 +30,18 @@ public void ParseHttp1_0() [Fact] public void ParseHttp1_1() { - StringBuilder sb = new StringBuilder(); - sb.AppendLine("HTTP/1.1 200 OK"); - sb.AppendLine(); - - Span bytes = Encoding.UTF8.GetBytes(sb.ToString()); - + var raw = "HTTP/1.1 200 OK\r\n\r\n"; + Span bytes = Encoding.UTF8.GetBytes(raw); HttpResponseParser parser = new HttpResponseParser(); try { Memory memory = parser.GetMemory(); - bytes.CopyTo(memory.Span); - - int writtenBytes = Math.Min(bytes.Length, memory.Length); - - bool b = parser.Parse(writtenBytes); + bool b = parser.Parse(bytes.Length); Assert.True(b); - - Assert.Equal(sb.ToString(), parser.ToString()); + Assert.Equal(raw, parser.ToString()); } finally { @@ -69,27 +52,19 @@ public void ParseHttp1_1() [Fact] public void ParseOneNewLine() { - StringBuilder sb = new StringBuilder(); - sb.AppendLine("HTTP/1.1 200 OK"); - //sb.AppendLine(); - - Span bytes = Encoding.UTF8.GetBytes(sb.ToString()); - + // Only one \r\n — no end-of-headers marker + var raw = "HTTP/1.1 200 OK\r\n"; + Span bytes = Encoding.UTF8.GetBytes(raw); HttpResponseParser parser = new HttpResponseParser(); try { Memory memory = parser.GetMemory(); - bytes.CopyTo(memory.Span); - - int writtenBytes = Math.Min(bytes.Length, memory.Length); - - bool b = parser.Parse(writtenBytes); + bool b = parser.Parse(bytes.Length); Assert.False(b); - - Assert.Equal(sb.ToString(), parser.ToString()); + Assert.Equal(raw, parser.ToString()); } finally { @@ -104,33 +79,145 @@ public void ParseOneNewLine() [InlineData(4)] public void ParseSegments(int segmentLength) { - StringBuilder sb = new StringBuilder(); - sb.AppendLine("HTTP/1.1 200 Connection established"); - sb.AppendLine(); + var raw = "HTTP/1.1 200 Connection established\r\n\r\n"; + Span bytes = Encoding.UTF8.GetBytes(raw); - Span bytes = Encoding.UTF8.GetBytes(sb.ToString()); HttpResponseParser parser = new HttpResponseParser(); - while (true) + try { - Memory memory = parser.GetMemory(); + while (bytes.Length > 0) + { + Memory memory = parser.GetMemory(); + var length = Math.Min(memory.Length, Math.Min(segmentLength, bytes.Length)); + bytes[..length].CopyTo(memory.Span); + bool b = parser.Parse(length); + + bytes = bytes[length..]; + + if (b) + { + Assert.True(parser.GetStatusCode() == 200); + Assert.Equal(raw, parser.ToString()); + return; + } + } + + Assert.Fail("Parser did not find end of headers"); + } + finally + { + parser.Dispose(); + } + } - var length = Math.Min(memory.Length, Math.Min(segmentLength, bytes.Length)); - - Span writtenBytes = bytes.Slice(0, length); + [Fact] + public void GetStatusCode_Various() + { + var cases = new[] { ("HTTP/1.1 200 OK\r\n\r\n", 200), ("HTTP/1.1 407 Auth\r\n\r\n", 407), ("HTTP/1.0 503 Unavailable\r\n\r\n", 503) }; - writtenBytes.CopyTo(memory.Span); - bool b = parser.Parse(writtenBytes.Length); - string gg = parser.ToString(); + foreach (var (raw, expectedCode) in cases) + { + var parser = new HttpResponseParser(); + try + { + var bytes = Encoding.UTF8.GetBytes(raw).AsSpan(); + parser.GetMemory().Span[..bytes.Length].CopyTo(parser.GetMemory().Span); + bytes.CopyTo(parser.GetMemory().Span); + parser.Parse(bytes.Length); + Assert.Equal(expectedCode, parser.GetStatusCode()); + } + finally + { + parser.Dispose(); + } + } + } - bytes = bytes.Slice(length); + [Fact] + public void GetStatusCode_Malformed_ReturnsNegativeOne() + { + var parser = new HttpResponseParser(); + try + { + var bytes = "GARBAGE\r\n\r\n"u8; + bytes.CopyTo(parser.GetMemory().Span); + parser.Parse(bytes.Length); + Assert.Equal(-1, parser.GetStatusCode()); + } + finally + { + parser.Dispose(); + } + } + + [Fact] + public void HasOverreadBytes_WhenExtraBytesAfterHeaders() + { + var parser = new HttpResponseParser(); + try + { + var raw = "HTTP/1.1 200 OK\r\n\r\nEXTRA"u8; + raw.CopyTo(parser.GetMemory().Span); + bool found = parser.Parse(raw.Length); - if (b) - break; + Assert.True(found); + Assert.True(parser.HasOverreadBytes); + Assert.Equal("EXTRA"u8.ToArray(), parser.OverreadBytes.ToArray()); + } + finally + { + parser.Dispose(); } + } - bool isValid = parser.GetStatusCode() == 200; + [Fact] + public void HasOverreadBytes_NoExtra_ReturnsFalse() + { + var parser = new HttpResponseParser(); + try + { + var raw = "HTTP/1.1 200 OK\r\n\r\n"u8; + raw.CopyTo(parser.GetMemory().Span); + parser.Parse(raw.Length); - Assert.True(isValid); - Assert.Equal(sb.ToString(), parser.ToString()); + Assert.False(parser.HasOverreadBytes); + Assert.True(parser.OverreadBytes.IsEmpty); + } + finally + { + parser.Dispose(); + } + } + + [Fact] + public void GetMemory_ThrowsWhenMaxHeaderSizeExceeded() + { + var parser = new HttpResponseParser(); + try + { + // Feed 16 KB of header-like data without \r\n\r\n + var chunk = "X-Header: value\r\n"u8; + while (true) + { + Memory mem; + try + { + mem = parser.GetMemory(); + } + catch (ProxyProtocolException ex) + { + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + return; // expected + } + + int len = Math.Min(chunk.Length, mem.Length); + chunk[..len].CopyTo(mem.Span); + parser.Parse(len); + } + } + finally + { + parser.Dispose(); + } } -} \ No newline at end of file +} diff --git a/QuickProxyNet.Tests/PrefixedStreamTest.cs b/QuickProxyNet.Tests/PrefixedStreamTest.cs new file mode 100644 index 0000000..9e70886 --- /dev/null +++ b/QuickProxyNet.Tests/PrefixedStreamTest.cs @@ -0,0 +1,81 @@ +using System.Text; + +namespace QuickProxyNet.Tests; + +public class PrefixedStreamTest +{ + /// + /// Creates a PrefixedStream by sending HTTP 200 + overread bytes through HttpHelper. + /// This is the only way to construct one since the class is private. + /// + private static async Task CreatePrefixedStreamAsync(byte[] overreadBytes, Stream innerStream) + { + var responseText = "HTTP/1.1 200 Connection established\r\n\r\n"; + var responseBytes = Encoding.UTF8.GetBytes(responseText); + var combined = new byte[responseBytes.Length + overreadBytes.Length]; + responseBytes.CopyTo(combined, 0); + overreadBytes.CopyTo(combined, responseBytes.Length); + + var fakeStream = new Helpers.FakeProxyStream(combined); + var result = await HttpHelper.EstablishHttpTunnelAsync( + fakeStream, new Uri("http://proxy:8080"), "target", 443, null, CancellationToken.None); + return result; + } + + [Fact] + public async Task Read_ReturnsPrefixBytesFirst_ThenInnerStream() + { + var overread = "HELLO"u8.ToArray(); + var prefixed = await CreatePrefixedStreamAsync(overread, Stream.Null); + + var buf = new byte[20]; + int read = await prefixed.ReadAsync(buf); + Assert.Equal(5, read); + Assert.Equal("HELLO", Encoding.UTF8.GetString(buf, 0, read)); + } + + [Fact] + public async Task Read_SmallBuffer_ReturnsPartialPrefix() + { + var overread = "ABCDEF"u8.ToArray(); + var prefixed = await CreatePrefixedStreamAsync(overread, Stream.Null); + + // Read only 3 bytes at a time + var buf = new byte[3]; + int read1 = await prefixed.ReadAsync(buf); + Assert.Equal(3, read1); + Assert.Equal("ABC", Encoding.UTF8.GetString(buf, 0, read1)); + + int read2 = await prefixed.ReadAsync(buf); + Assert.Equal(3, read2); + Assert.Equal("DEF", Encoding.UTF8.GetString(buf, 0, read2)); + } + + [Fact] + public async Task SyncRead_ReturnsPrefixBytesFirst() + { + var overread = "SYNC"u8.ToArray(); + var prefixed = await CreatePrefixedStreamAsync(overread, Stream.Null); + + var buf = new byte[10]; + int read = prefixed.Read(buf); + Assert.Equal(4, read); + Assert.Equal("SYNC", Encoding.UTF8.GetString(buf, 0, read)); + } + + [Fact] + public async Task CanRead_IsTrue() + { + var overread = "X"u8.ToArray(); + var prefixed = await CreatePrefixedStreamAsync(overread, Stream.Null); + Assert.True(prefixed.CanRead); + } + + [Fact] + public async Task CanSeek_IsFalse() + { + var overread = "X"u8.ToArray(); + var prefixed = await CreatePrefixedStreamAsync(overread, Stream.Null); + Assert.False(prefixed.CanSeek); + } +} diff --git a/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj b/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj index 994fd6a..511c421 100644 --- a/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj +++ b/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable diff --git a/QuickProxyNet.Tests/Socks5HelperTest.cs b/QuickProxyNet.Tests/Socks5HelperTest.cs new file mode 100644 index 0000000..4e6ce8f --- /dev/null +++ b/QuickProxyNet.Tests/Socks5HelperTest.cs @@ -0,0 +1,180 @@ +using System.Buffers.Binary; +using System.Net; +using System.Text; +using QuickProxyNet.Tests.Helpers; + +namespace QuickProxyNet.Tests; + +public class Socks5HelperTest +{ + /// + /// Builds a SOCKS5 success response for a domain-name connect request with no auth. + /// + private static byte[] BuildSocks5NoAuthSuccessResponse(string host, int port) + { + using var ms = new MemoryStream(); + // Method selection: version 5, method 0 (no auth) + ms.Write([5, 0]); + + // Connect reply: VER=5, REP=0 (success), RSV=0, ATYP=3 (domain), len, domain, port + ms.Write([5, 0, 0, 3]); + var hostBytes = Encoding.UTF8.GetBytes(host); + ms.WriteByte((byte)hostBytes.Length); + ms.Write(hostBytes); + Span portBuf = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(portBuf, (ushort)port); + ms.Write(portBuf); + return ms.ToArray(); + } + + /// + /// Builds a SOCKS5 success response for a connect with username/password auth. + /// + private static byte[] BuildSocks5AuthSuccessResponse(string host, int port) + { + using var ms = new MemoryStream(); + // Method selection: version 5, method 2 (username/password) + ms.Write([5, 2]); + + // Auth sub-negotiation: version 1, status 0 (success) + ms.Write([1, 0]); + + // Connect reply + ms.Write([5, 0, 0, 3]); + var hostBytes = Encoding.UTF8.GetBytes(host); + ms.WriteByte((byte)hostBytes.Length); + ms.Write(hostBytes); + Span portBuf = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(portBuf, (ushort)port); + ms.Write(portBuf); + return ms.ToArray(); + } + + [Fact] + public async Task Socks5_NoAuth_Success() + { + var response = BuildSocks5NoAuthSuccessResponse("example.com", 443); + var stream = new FakeProxyStream(response); + + await SocksHelper.EstablishSocks5TunnelAsync(stream, "example.com", 443, null, CancellationToken.None); + + // Verify handshake was sent: VER=5, NMETHODS=1, METHOD=0 + var written = stream.WrittenBytes; + Assert.Equal(5, written[0]); // version + Assert.Equal(1, written[1]); // 1 method + Assert.Equal(0, written[2]); // no-auth + } + + [Fact] + public async Task Socks5_WithAuth_Success() + { + var response = BuildSocks5AuthSuccessResponse("example.com", 443); + var stream = new FakeProxyStream(response); + var creds = new NetworkCredential("testuser", "testpass"); + + await SocksHelper.EstablishSocks5TunnelAsync(stream, "example.com", 443, creds, CancellationToken.None); + + var written = stream.WrittenBytes; + // Verify handshake: VER=5, NMETHODS=2, METHOD_NO_AUTH=0, METHOD_USER_PASS=2 + Assert.Equal(5, written[0]); + Assert.Equal(2, written[1]); + Assert.Equal(0, written[2]); + Assert.Equal(2, written[3]); + + // Verify auth sub-negotiation was sent after the 4-byte handshake + // Format: VER=1, ULEN, UNAME, PLEN, PASSWD + Assert.Equal(1, written[4]); // sub-negotiation version + Assert.Equal(8, written[5]); // "testuser" length + Assert.Equal("testuser", Encoding.UTF8.GetString(written, 6, 8)); + Assert.Equal(8, written[14]); // "testpass" length + Assert.Equal("testpass", Encoding.UTF8.GetString(written, 15, 8)); + } + + [Fact] + public async Task Socks5_AuthFailed_Throws() + { + using var ms = new MemoryStream(); + // Method selection: pick username/password + ms.Write([5, 2]); + // Auth sub-negotiation: version 1, status 1 (failure) + ms.Write([1, 1]); + var response = ms.ToArray(); + + var stream = new FakeProxyStream(response); + var creds = new NetworkCredential("user", "wrong"); + + var ex = await Assert.ThrowsAsync( + () => SocksHelper.EstablishSocks5TunnelAsync(stream, "example.com", 443, creds, CancellationToken.None) + .AsTask()); + + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); + } + + [Fact] + public async Task Socks5_NoSuitableMethod_Throws() + { + // Server returns 0xFF (no acceptable methods) + var response = new byte[] { 5, 0xFF }; + var stream = new FakeProxyStream(response); + + var ex = await Assert.ThrowsAsync( + () => SocksHelper.EstablishSocks5TunnelAsync(stream, "example.com", 443, null, CancellationToken.None) + .AsTask()); + + Assert.Equal(ProxyErrorCode.SocksNoAuthMethod, ex.ErrorCode); + } + + [Fact] + public async Task Socks5_ConnectFailed_Throws() + { + using var ms = new MemoryStream(); + // Method selection: no auth + ms.Write([5, 0]); + // Connect reply: VER=5, REP=5 (connection refused), RSV=0, ATYP=1 (IPv4), 0.0.0.0:0 + ms.Write([5, 5, 0, 1, 0, 0, 0, 0, 0, 0]); + var response = ms.ToArray(); + + var stream = new FakeProxyStream(response); + + var ex = await Assert.ThrowsAsync( + () => SocksHelper.EstablishSocks5TunnelAsync(stream, "example.com", 443, null, CancellationToken.None) + .AsTask()); + + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + } + + [Fact] + public async Task Socks5_WrongVersion_Throws() + { + // Server returns version 4 instead of 5 + var response = new byte[] { 4, 0 }; + var stream = new FakeProxyStream(response); + + var ex = await Assert.ThrowsAsync( + () => SocksHelper.EstablishSocks5TunnelAsync(stream, "example.com", 443, null, CancellationToken.None) + .AsTask()); + + Assert.Equal(ProxyErrorCode.SocksUnexpectedVersion, ex.ErrorCode); + } + + [Fact] + public async Task Socks5_IPv4Address_EncodesCorrectly() + { + var response = BuildSocks5NoAuthSuccessResponse("1.2.3.4", 80); + var stream = new FakeProxyStream(response); + + await SocksHelper.EstablishSocks5TunnelAsync(stream, "1.2.3.4", 80, null, CancellationToken.None); + + var written = stream.WrittenBytes; + // After handshake (3 bytes), connect request starts + int offset = 3; + Assert.Equal(5, written[offset]); // VER + Assert.Equal(1, written[offset + 1]); // CMD = CONNECT + Assert.Equal(0, written[offset + 2]); // RSV + Assert.Equal(1, written[offset + 3]); // ATYP = IPv4 + Assert.Equal(1, written[offset + 4]); // 1. + Assert.Equal(2, written[offset + 5]); // 2. + Assert.Equal(3, written[offset + 6]); // 3. + Assert.Equal(4, written[offset + 7]); // 4 + } +} diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index 5cd9019..d48080f 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -1,5 +1,6 @@ -using System.Net; +using System.Net; using System.Net.Sockets; +using System.Runtime.CompilerServices; namespace QuickProxyNet; @@ -14,20 +15,18 @@ private ProxyClient(Uri uri) if (!string.IsNullOrWhiteSpace(uri.UserInfo)) { - var credentials = uri.UserInfo.Split(':'); - if (credentials.Length != 2) - { + var sep = uri.UserInfo.IndexOf(':'); + if (sep < 0) throw new ArgumentException("Invalid credentials format.", nameof(uri.UserInfo)); - } - ProxyCredentials = new NetworkCredential(credentials[0], credentials[1]); + ProxyCredentials = new NetworkCredential( + uri.UserInfo.Substring(0, sep), + uri.UserInfo.Substring(sep + 1)); } } protected ProxyClient(string protocol, string host, int port) { - ProxyUri = new Uri($"{protocol}://{host}:{port}"); - if (host == null) throw new ArgumentNullException(nameof(host)); @@ -40,6 +39,7 @@ protected ProxyClient(string protocol, string host, int port) ProxyHost = host; ProxyPort = port == 0 ? 1080 : port; + ProxyUri = new Uri($"{protocol}://{host}:{port}"); } protected ProxyClient(string protocol, string host, int port, NetworkCredential credentials) @@ -80,14 +80,6 @@ protected ProxyClient(string protocol, string host, int port, NetworkCredential public int WriteTimeout { get; set; } public int ReadTimeout { get; set; } - - private static void OnDisposeSocket(object? state) - { - if (state is Socket socket) - { - socket.Dispose(); - } - } private Socket CreateSocket() { @@ -112,15 +104,15 @@ public async ValueTask ConnectAsync(string host, int port, CancellationT var socket = CreateSocket(); - await using var reg = cancellationToken.Register(OnDisposeSocket, socket); try { await socket.ConnectAsync(ProxyHost, ProxyPort, cancellationToken); } - catch + catch (Exception ex) { socket.Dispose(); - throw; + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"Failed to connect to proxy {ProxyHost}:{ProxyPort} for target {host}:{port}.", ex); } var stream = new NetworkStream(socket, true); @@ -135,9 +127,6 @@ public async ValueTask ConnectAsync(string host, int port, CancellationT } } - - - public virtual async ValueTask ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken cancellationToken = default) { @@ -146,20 +135,29 @@ public virtual async ValueTask ConnectAsync(string host, int port, TimeS cancellationToken.ThrowIfCancellationRequested(); var socket = CreateSocket(); - await using var reg = cancellationToken.Register(static s => ((IDisposable?)s)?.Dispose(), socket); - - await using ITimer timer = - TimeProvider.System.CreateTimer(OnDisposeSocket, socket, timeout, Timeout.InfiniteTimeSpan); + var timedOut = new StrongBox(false); + await using ITimer timer = TimeProvider.System.CreateTimer( + static s => + { + var state = (Tuple>)s!; + Volatile.Write(ref state.Item2.Value, true); + state.Item1.Dispose(); + }, + Tuple.Create(socket, timedOut), timeout, Timeout.InfiniteTimeSpan); try { await socket.ConnectAsync(ProxyHost, ProxyPort, cancellationToken); } - catch + catch (Exception ex) { socket.Dispose(); - throw; + if (Volatile.Read(ref timedOut.Value)) + throw new ProxyProtocolException(ProxyErrorCode.Timeout, + $"Connection to proxy {ProxyHost}:{ProxyPort} timed out after {timeout}.", ex); + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"Failed to connect to proxy {ProxyHost}:{ProxyPort} for target {host}:{port}.", ex); } var stream = new NetworkStream(socket, true); @@ -167,9 +165,12 @@ public virtual async ValueTask ConnectAsync(string host, int port, TimeS { return await ConnectAsync(stream, host, port, cancellationToken); } - catch + catch (Exception ex) { await stream.DisposeAsync(); + if (Volatile.Read(ref timedOut.Value)) + throw new ProxyProtocolException(ProxyErrorCode.Timeout, + $"Connection to proxy {ProxyHost}:{ProxyPort} timed out after {timeout}.", ex); throw; } } @@ -189,4 +190,4 @@ internal static void ValidateArguments(string host, int port) if (port <= 0 || port > 65535) throw new ArgumentOutOfRangeException(nameof(port)); } -} \ No newline at end of file +} diff --git a/QuickProxyNet/Internal/HttpHelper.cs b/QuickProxyNet/Internal/HttpHelper.cs index 59bef7a..478c9c0 100644 --- a/QuickProxyNet/Internal/HttpHelper.cs +++ b/QuickProxyNet/Internal/HttpHelper.cs @@ -103,7 +103,8 @@ internal static async ValueTask EstablishHttpTunnelAsync(Stream stream, var memory = parser.GetMemory(); int nread = await stream.ReadAsync(memory, cancellationToken); if (nread <= 0) - throw new EndOfStreamException("Proxy closed connection unexpectedly."); + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"Proxy closed connection unexpectedly while establishing tunnel to {host}:{port}."); found = parser.Parse(nread); } while (!found); diff --git a/QuickProxyNet/Internal/HttpResponseParser.cs b/QuickProxyNet/Internal/HttpResponseParser.cs index 5b6b9ad..1a1220b 100644 --- a/QuickProxyNet/Internal/HttpResponseParser.cs +++ b/QuickProxyNet/Internal/HttpResponseParser.cs @@ -7,6 +7,12 @@ internal struct HttpResponseParser : IDisposable { private const int BufferSize = 1024; + /// + /// Maximum allowed response header size (16 KB). + /// Prevents unbounded memory growth if a malicious proxy sends endless data without \r\n\r\n. + /// + private const int MaxHeaderSize = 16 * 1024; + private byte[] _buffer; private int _writtenCount; private int _indexEnd; // absolute index of '\r\n\r\n' start, or -1 @@ -22,6 +28,10 @@ public HttpResponseParser() public Memory GetMemory() { + if (_writtenCount >= MaxHeaderSize) + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + $"Proxy response headers exceeded the maximum allowed size of {MaxHeaderSize} bytes."); + if (_writtenCount < _buffer.Length) return _buffer.AsMemory(_writtenCount); diff --git a/QuickProxyNet/Internal/SocksHelper.cs b/QuickProxyNet/Internal/SocksHelper.cs index 605e3d9..aa103d3 100644 --- a/QuickProxyNet/Internal/SocksHelper.cs +++ b/QuickProxyNet/Internal/SocksHelper.cs @@ -97,7 +97,10 @@ await stream.WriteAsync(buffer.AsMemory(0, 3 + usernameLength + passwordLength), // | 1 | 1 | // +----+--------+ await stream.ReadExactlyAsync(buffer.AsMemory(0, 2), cancellationToken).ConfigureAwait(false); - if (buffer[0] != SubnegotiationVersion || buffer[1] != Socks5_Success) + if (buffer[0] != SubnegotiationVersion) + throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, + $"Unexpected SOCKS5 auth subnegotiation version. Expected {SubnegotiationVersion}, got {buffer[0]}."); + if (buffer[1] != Socks5_Success) throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, "Failed to authenticate with the SOCKS server."); break; } @@ -155,7 +158,8 @@ await stream.WriteAsync(buffer.AsMemory(0, 3 + usernameLength + passwordLength), await stream.ReadExactlyAsync(buffer.AsMemory(0, 5), cancellationToken).ConfigureAwait(false); VerifyProtocolVersion(ProtocolVersion5, buffer[0]); if (buffer[1] != Socks5_Success) - throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, "SOCKS server failed to connect to the destination."); + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"SOCKS5 server rejected connection to {host}:{port} (reply code: 0x{buffer[1]:X2})."); var bytesToSkip = buffer[3] switch { ATYP_IPV4 => 5, @@ -257,6 +261,10 @@ await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationTo await stream.ReadExactlyAsync(buffer.AsMemory(0, 8), cancellationToken).ConfigureAwait(false); + if (buffer[0] != 0) + throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, + $"Unexpected SOCKS4 reply version. Expected 0, got {buffer[0]}."); + switch (buffer[1]) { case Socks4_Success: @@ -281,7 +289,7 @@ private static byte EncodeString(ReadOnlySpan chars, Span buffer, st { return checked((byte)Encoding.UTF8.GetBytes(chars, buffer)); } - catch + catch (ArgumentException) { Debug.Assert(Encoding.UTF8.GetByteCount(chars) > 255); throw new ProxyProtocolException(ProxyErrorCode.SocksStringTooLong, $"Encoding the {parameterName} took more than the maximum of 255 bytes"); diff --git a/QuickProxyNet/Proxy.cs b/QuickProxyNet/Proxy.cs index aef9e09..c7d7571 100644 --- a/QuickProxyNet/Proxy.cs +++ b/QuickProxyNet/Proxy.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Sockets; +using System.Runtime.CompilerServices; namespace QuickProxyNet; @@ -89,19 +90,33 @@ private static async ValueTask ConnectCoreAsync(Uri proxyUri, string hos }; ITimer? timer = null; + StrongBox? timedOut = null; if (timeout.HasValue) - timer = TimeProvider.System.CreateTimer(static s => ((Socket)s!).Dispose(), socket, - timeout.Value, Timeout.InfiniteTimeSpan); + { + timedOut = new StrongBox(false); + timer = TimeProvider.System.CreateTimer( + static s => + { + var state = (Tuple>)s!; + Volatile.Write(ref state.Item2.Value, true); + state.Item1.Dispose(); + }, + Tuple.Create(socket, timedOut), timeout.Value, Timeout.InfiniteTimeSpan); + } try { await socket.ConnectAsync(proxyUri.Host, proxyUri.Port, cancellationToken); } - catch + catch (Exception ex) { if (timer is not null) await timer.DisposeAsync(); socket.Dispose(); - throw; + if (timedOut is not null && Volatile.Read(ref timedOut.Value)) + throw new ProxyProtocolException(ProxyErrorCode.Timeout, + $"Connection to proxy {proxyUri.Host}:{proxyUri.Port} timed out after {timeout!.Value}.", ex); + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"Failed to connect to proxy {proxyUri.Host}:{proxyUri.Port} for target {host}:{port}.", ex); } var stream = new NetworkStream(socket, ownsSocket: true); @@ -109,18 +124,20 @@ private static async ValueTask ConnectCoreAsync(Uri proxyUri, string hos { var result = await ProxyConnector.ConnectToProxyAsync(stream, proxyUri, host, port, credentials, cancellationToken); + // Dispose timer before returning to prevent race where timer fires + // and destroys the socket after we hand the stream to the caller. + if (timer is not null) await timer.DisposeAsync(); return result; } - catch + catch (Exception ex) { if (timer is not null) await timer.DisposeAsync(); await stream.DisposeAsync(); + if (timedOut is not null && Volatile.Read(ref timedOut.Value)) + throw new ProxyProtocolException(ProxyErrorCode.Timeout, + $"Connection to proxy {proxyUri.Host}:{proxyUri.Port} timed out after {timeout!.Value}.", ex); throw; } - finally - { - if (timer is not null) await timer.DisposeAsync(); - } } private static NetworkCredential? ParseCredentials(Uri proxyUri) diff --git a/QuickProxyNet/ProxyClientFactory.cs b/QuickProxyNet/ProxyClientFactory.cs index e0f2c2e..f423b94 100644 --- a/QuickProxyNet/ProxyClientFactory.cs +++ b/QuickProxyNet/ProxyClientFactory.cs @@ -35,8 +35,12 @@ public IProxyClient Create(Uri proxyUri) if (!string.IsNullOrEmpty(proxyUri.UserInfo)) { - var userAndPass = proxyUri.UserInfo.Split(':'); - credential = new NetworkCredential(userAndPass[0], userAndPass[1]); + var sep = proxyUri.UserInfo.IndexOf(':'); + credential = sep < 0 + ? new NetworkCredential(proxyUri.UserInfo, string.Empty) + : new NetworkCredential( + proxyUri.UserInfo.Substring(0, sep), + proxyUri.UserInfo.Substring(sep + 1)); } return Create(type, proxyUri.Host, proxyUri.Port, credential); diff --git a/QuickProxyNet/ProxyConnector.cs b/QuickProxyNet/ProxyConnector.cs index 9b17613..c4d314a 100644 --- a/QuickProxyNet/ProxyConnector.cs +++ b/QuickProxyNet/ProxyConnector.cs @@ -52,15 +52,16 @@ await SocksHelper try { await ssl.AuthenticateAsClientAsync(DefaultSslOptions(proxyUri.Host), cancellationToken); + return await HttpHelper.EstablishHttpTunnelAsync(ssl, proxyUri, host, port, credentials, + cancellationToken); } catch { - ssl.Dispose(); + await ssl.DisposeAsync().ConfigureAwait(false); + // SslStream(leaveOpen:false) disposes inner stream, + // so skip the outer catch to avoid double-dispose. throw; } - - return await HttpHelper.EstablishHttpTunnelAsync(ssl, proxyUri, host, port, credentials, - cancellationToken); } throw new NotSupportedException($"Unsupported proxy scheme: {proxyUri.Scheme}"); diff --git a/QuickProxyNet/ProxyProtocolException.cs b/QuickProxyNet/ProxyProtocolException.cs index b745723..51200e1 100644 --- a/QuickProxyNet/ProxyProtocolException.cs +++ b/QuickProxyNet/ProxyProtocolException.cs @@ -50,5 +50,7 @@ public enum ProxyErrorCode /// SOCKS4 does not support IPv6 addresses. SocksIPv6NotSupported, /// Failed to resolve host to an IPv4 address (required for SOCKS4). - SocksNoIPv4Address + SocksNoIPv4Address, + /// The proxy connection timed out. + Timeout } From 0c1218b03548226d6964db54c4cf980d33fa63e2 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Sun, 8 Mar 2026 18:11:20 +0500 Subject: [PATCH 5/5] docs: update README for v3.0.0, add .NET 10 to CI workflows - Rewrite README with static API examples, error codes table, and features - Update NuGet readme with Proxy.ConnectAsync and extension method examples - Add .NET 10.x to build.yaml and publish.yaml CI workflows Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yaml | 1 + .github/workflows/publish.yaml | 1 + QuickProxyNet/README.md | 57 ++++++++---- README.md | 157 ++++++++++++++++++++++----------- 4 files changed, 146 insertions(+), 70 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 22f23c6..2965628 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -18,6 +18,7 @@ jobs: dotnet-version: | 8.x 9.x + 10.x - name: Restore run: dotnet restore diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 8fa2847..f531c71 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -18,6 +18,7 @@ jobs: dotnet-version: | 8.x 9.x + 10.x - name: Restore run: dotnet restore diff --git a/QuickProxyNet/README.md b/QuickProxyNet/README.md index d156471..0fcf12c 100644 --- a/QuickProxyNet/README.md +++ b/QuickProxyNet/README.md @@ -1,29 +1,50 @@ # QuickProxyNet -QuickProxyNet is a high-performance .NET library for connecting to servers via various types of proxies (HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5). It offers raw Stream access for direct data handling, making it ideal for applications needing low-level network control. +High-performance, zero-dependency C# library for connecting through HTTP, HTTPS, SOCKS4, SOCKS4a, and SOCKS5 proxies. Returns a raw `Stream` for direct data access. -## Features -- Supports HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5 proxies -- High-performance and minimal latency -- Raw Stream Access for low-level data operations -- Customizable timeout and connection settings +**Targets:** .NET 8 / .NET 9 / .NET 10 + +## Quick Start + +```csharp +// One-liner — ideal for mass proxy checking +await using var stream = await Proxy.ConnectAsync( + new Uri("socks5://user:pass@127.0.0.1:1080"), + "example.com", 443, + TimeSpan.FromSeconds(5)); +``` -## Usage Example +Or via extension method: ```csharp -using QuickProxyNet; -using System; -using System.Net; +await using var stream = await new Uri("http://proxy:8080") + .ConnectThroughProxyAsync("example.com", 443); +``` + +## Features -// Define the proxy URI with optional credentials -Uri proxyUri = new Uri("http://username:password@proxyserver.com:8080"); +- Zero runtime dependencies (BCL only) +- Zero-alloc protocol logic (`ArrayPool`, `stackalloc`, `Utf8Formatter`, `ValueTask`) +- Structured errors: `ProxyProtocolException` with `ProxyErrorCode` enum +- Per-connection timeouts with `ProxyErrorCode.Timeout` +- Static API (`Proxy.ConnectAsync`) and factory API (`ProxyClientFactory`) -// Create a proxy client -var proxyClient = ProxyClientFactory.Instance.Create(proxyUri); +## Error Handling -// Connect through the proxy and get a raw Stream for direct data access -using (var connectionStream = await proxyClient.ConnectAsync("destinationserver.com", 80)) +```csharp +try +{ + await using var stream = await Proxy.ConnectAsync(proxyUri, host, port, + TimeSpan.FromSeconds(5)); +} +catch (ProxyProtocolException ex) when (ex.ErrorCode == ProxyErrorCode.Timeout) { -// Work directly with the Stream + // Timed out — ex.Message includes proxy and target host:port } -``` \ No newline at end of file +catch (ProxyProtocolException ex) when (ex.ErrorCode == ProxyErrorCode.AuthFailed) +{ + // Wrong credentials +} +``` + +See [full documentation](https://github.com/Titlehhhh/QuickProxyNet) for all error codes and configuration options. diff --git a/README.md b/README.md index deb71dc..d105702 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,143 @@ ![](icon.png) [![NuGet version (QuickProxyNet)](https://img.shields.io/nuget/v/QuickProxyNet?style=flat-square)](https://www.nuget.org/packages/QuickProxyNet/) +[![Build](https://img.shields.io/github/actions/workflow/status/Titlehhhh/QuickProxyNet/build.yaml?branch=master&style=flat-square)](https://github.com/Titlehhhh/QuickProxyNet/actions) # QuickProxyNet -**QuickProxyNet** is a high-performance C# library for connecting to servers via various types of proxies, providing direct and efficient access to the underlying data stream. It supports a range of proxy protocols, including HTTP, HTTPS, SOCKS4, SOCKS4a, and SOCKS5, enabling seamless integration into .NET applications requiring proxy connections. +**QuickProxyNet** is a high-performance, zero-dependency C# library for connecting to servers through proxy protocols. It provides direct `Stream` access with minimal allocations and latency — ideal for mass proxy checking, crawlers, and any scenario where thousands of proxy connections are made in parallel. + +**Targets:** .NET 8 / .NET 9 / .NET 10 ## Features -- **High-Performance Connection Handling**: Designed for minimal latency and optimal resource usage, allowing for efficient handling of high-load scenarios. -- **Support for Multiple Proxy Types**: Connect via HTTP, HTTPS, SOCKS4, SOCKS4a, and SOCKS5 proxies. -- **Raw Stream Access**: Upon connection, QuickProxyNet provides a raw Stream to directly interact with data, making it highly suitable for applications needing low-level network control. -- **Customizable Timeout and Connection Settings**: Configure timeouts, connection endpoints, and proxy settings for optimal network performance. +- **Zero runtime dependencies** — BCL only, no third-party packages +- **Zero-alloc protocol logic** — `ArrayPool`, `stackalloc`, `Utf8Formatter`, `ValueTask` throughout +- **5 proxy protocols** — HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5 +- **Static one-liner API** — `Proxy.ConnectAsync(uri, host, port)` for mass checkers +- **Structured error codes** — `ProxyProtocolException` with `ProxyErrorCode` enum for programmatic error handling +- **Timeout support** — per-connection timeouts with `ProxyErrorCode.Timeout` +- **Raw Stream access** — full control over the tunneled connection ## Installation -Clone the repository or install via your preferred NuGet package manager (if available): - -```dotnet add package QuickProxyNet``` +``` +dotnet add package QuickProxyNet +``` +## Quick Start -## Usage +### One-liner (recommended for mass checking) -The library offers a ProxyClientFactory class to easily create proxy connections. Here’s a quick guide on how to use it. +```csharp +// Single call — no intermediate objects allocated +await using var stream = await Proxy.ConnectAsync( + new Uri("socks5://user:pass@127.0.0.1:1080"), + "example.com", 443, + TimeSpan.FromSeconds(5)); +``` -### Example: Creating a Proxy Client +### Extension method on Uri -The following example shows how to create a proxy client using a URI and connect to a remote server through it: ```csharp -using System; -using System.Net; -using QuickProxyNet; +var proxy = new Uri("http://proxy.example.com:8080"); +await using var stream = await proxy.ConnectThroughProxyAsync("example.com", 443); +``` -// URI of the proxy server, including protocol (e.g., "http", "socks5") -Uri proxyUri = new Uri("http://username:password@proxyserver.com:8080"); +### Factory API (when you need to configure the client) -// Create a proxy client -var client = ProxyClientFactory.Instance.Create(proxyUri); +```csharp +var client = ProxyClientFactory.Instance.Create(new Uri("socks5://proxy:1080")); +client.NoDelay = true; +client.ReadTimeout = 5000; -// Connect to the target server through the proxy and get a raw Stream for direct data access -using (var connectionStream = await client.ConnectAsync("destinationserver.com", 80)) -{ - // Work with the Stream directly (e.g., send and receive data) - // Example: Send HTTP request, work with binary data, etc. -} +await using var stream = await client.ConnectAsync("example.com", 443); ``` -### Example: Creating a Proxy Client with Custom Configuration -You can also specify the proxy type, host, port, and optional credentials: +### With explicit proxy type and credentials + ```csharp -using System; -using System.Net; -using QuickProxyNet; +var creds = new NetworkCredential("user", "pass"); +var client = ProxyClientFactory.Instance.Create( + ProxyType.Socks5, "proxy.example.com", 1080, creds); + +await using var stream = await client.ConnectAsync("example.com", 80, + TimeSpan.FromSeconds(10)); +``` -// Define proxy details -ProxyType proxyType = ProxyType.Socks5; -string proxyHost = "proxyserver.com"; -int proxyPort = 1080; -NetworkCredential credentials = new NetworkCredential("username", "password"); +## Error Handling -// Create the proxy client with specified configuration -var client = ProxyClientFactory.Instance.Create(proxyType, proxyHost, proxyPort, credentials); +All proxy protocol errors throw `ProxyProtocolException` with a specific `ProxyErrorCode`: -// Connect to the target server and obtain a raw Stream -using (var connectionStream = await client.ConnectAsync("destinationserver.com", 80)) +```csharp +try +{ + await using var stream = await Proxy.ConnectAsync(proxyUri, host, port, + TimeSpan.FromSeconds(5)); +} +catch (ProxyProtocolException ex) { - // Directly work with the Stream for low-level network operations + switch (ex.ErrorCode) + { + case ProxyErrorCode.Timeout: + // Connection timed out + break; + case ProxyErrorCode.ConnectionFailed: + // Could not reach the proxy (includes proxy host:port in message) + break; + case ProxyErrorCode.AuthRequired: + // Proxy requires credentials (HTTP 407) + break; + case ProxyErrorCode.AuthFailed: + // Wrong username/password + break; + case ProxyErrorCode.InvalidResponse: + // Proxy returned garbage + break; + } + // ex.Message includes proxy host:port and target host:port + // ex.InnerException contains the original SocketException/IOException } ``` -### Supported Proxy Types -- **HTTP**: Standard HTTP proxy with CONNECT support for tunneling. -- **HTTPS**: Supports SSL/TLS tunneling. -- **SOCKS4**: Supports IP-based connections only. -- **SOCKS4a**: Adds support for domain name resolution through the proxy. -- **SOCKS5**: Full-featured SOCKS proxy with authentication and DNS support. +### Error Codes + +| Code | Description | +|---|---| +| `Timeout` | Connection timed out | +| `ConnectionFailed` | Failed to connect to the proxy server | +| `AuthRequired` | Proxy requires authentication (HTTP 407) | +| `AuthFailed` | Authentication credentials rejected | +| `InvalidResponse` | Proxy returned an invalid/unparseable response | +| `SocksUnexpectedVersion` | SOCKS protocol version mismatch | +| `SocksNoAuthMethod` | No suitable SOCKS5 auth method | +| `SocksBadAddressType` | Unknown SOCKS5 address type | +| `SocksIPv6NotSupported` | SOCKS4 does not support IPv6 | +| `SocksNoIPv4Address` | Failed to resolve host to IPv4 (SOCKS4) | +| `SocksStringTooLong` | SOCKS field exceeded 255-byte limit | + +## Supported Proxy Types + +| Type | Protocol | Auth | DNS through proxy | +|---|---|---|---| +| `Http` | HTTP CONNECT | Basic | No | +| `Https` | HTTPS CONNECT + TLS | Basic | No | +| `Socks4` | SOCKS4 | UserId | No (resolved locally) | +| `Socks4a` | SOCKS4a | UserId | Yes | +| `Socks5` | SOCKS5 (RFC 1928) | Username/Password (RFC 1929) | Yes | ## Configuration Options -Each proxy client supports various configurations to fine-tune network behavior, including: +When using the factory/client API, each client supports: -- **WriteTimeout** and **ReadTimeout**: Specify timeouts for data send/receive operations. -- **LocalEndPoint**: Set a local endpoint for the connection if required. -- **NoDelay**: Option to disable the Nagle algorithm for reduced latency in data transmission. -- **LingerState**: Configure linger behavior for connection closure. +| Property | Default | Description | +|---|---|---| +| `NoDelay` | `true` | Disable Nagle algorithm | +| `LingerState` | `Linger(true, 0)` | Socket linger on close | +| `ReadTimeout` | `0` (infinite) | Read timeout in ms | +| `WriteTimeout` | `0` (infinite) | Write timeout in ms | +| `LocalEndPoint` | `null` | Bind to specific local IP | ## License -This library is licensed under the MIT License \ No newline at end of file +MIT