QuickProxyNet is a high-performance C#/.NET library for opening direct Stream
connections through proxy protocols. It covers the classic proxy family (HTTP,
HTTPS, SOCKS4, SOCKS4a, SOCKS5) and the VPN-style family (VLESS, Trojan, VMess).
VLESS REALITY, with the xtls-rprx-vision flow, is part of the core: a managed
TLS 1.3 client, no external binary, no extra package. See "REALITY" below.
- NuGet package:
QuickProxyNet - Author: Titlehhhh
- License: MIT
- Core targets:
net8.0,net9.0,net10.0,net11.0
QuickProxyNet/ Core library and protocol logic
QuickProxyNet.Tests/ xUnit tests
QuickProxyNet.Benchmarks/ BenchmarkDotNet benchmarks
Sample/ Console usage example
build/ NUKE build automation
docs/ Protocol notes and implementation research
tools/CorpusCheck/ Manual diagnostic: share-link parsers vs a real-world corpus
tests/docker/ Xray + sing-box servers for the integration tests
tools/CorpusCheck is deliberately not in QuickProxyNet.slnx: it is a
hand-run diagnostic, and keeping it out of the solution keeps it out of CI.
All public library types live in the QuickProxyNet namespace.
Proxyis the static entry point: one-callConnectAsync(...)helpers, plusCreate(...)/TryCreate(...)building a client from a share-linkstring, from aUri, or from explicit proxy settings.- There are no
Uriconnect overloads and noIProxyClient.ProxyUri(removed in 5.0.0). AUricannot hold mostvmess://links, keeps nothing but host and port for the other VPN-style families, and could not even hold a password containing@.Proxy.Create(Uri)stays as an adapter forWebProxy.AddressandIWebProxy.GetProxy;ProxyClient.ToString()isscheme://host:portfor logs. - There is no
IProxyClient.ReadTimeout/WriteTimeout(removed in 5.0.0). They were copied toSocket.ReceiveTimeout/SendTimeout, which bind only synchronous calls, so they never applied to a handshake. Do not bring them back: theTimeSpanoverloads ofConnectAsyncbound the handshake, and a caller bounds reads on the returned stream. IProxyClientis the client contract; connection methods returnValueTask<Stream>.SourceLinkcarries the text the client was built from. A target ishost, portor anEndPoint(DnsEndPoint/IPEndPoint); theEndPointoverloads are default interface members that forward to the host-and-port ones, so an implementation outsideProxyClientgets them free.ProxyClientowns common socket setup, timeout handling, and argument validation.ProxyProtocolExceptioncarries a structuredProxyErrorCode.VlessOptions/TrojanOptions/VmessOptionsplus the matching*ShareLink.Parse/TryParsedescribe a VPN-style endpoint.
REALITY lives in QuickProxyNet/Internal/Reality/ and is reached through
VlessClient like any other security mode; RealityHandshakeException is the one
public type it adds.
| Class | Protocol |
|---|---|
HttpProxyClient |
HTTP CONNECT |
HttpsProxyClient |
HTTPS CONNECT over TLS |
Socks4Client |
SOCKS4 |
Socks4aClient |
SOCKS4a |
Socks5Client |
SOCKS5 with optional username/password auth |
VlessClient |
VLESS, security=none, tls or reality, with or without xtls-rprx-vision |
TrojanClient |
Trojan over TLS |
VmessClient |
VMess (VMessAEAD, alterId=0), optional TLS |
All three run over any of three transports: tcp/raw, ws/websocket, httpupgrade.
grpc, xhttp and h2 are rejected with NotSupportedException before any byte is
written.
Internal protocol helpers live under QuickProxyNet/Internal/:
Internal/HttpHelper.cs HTTP CONNECT request/response
Internal/HttpResponseParser.cs
Internal/SocksHelper.cs SOCKS4/4a/5
Internal/ProxyAddress.cs shared atyp/host/port encoding
Internal/VlessHelper.cs VLESS request header
Internal/VlessResponseStream.cs lazy VLESS response-header reader
Internal/TrojanHelper.cs Trojan request header
Internal/PrefixedStream.cs replays handshake overread bytes
Internal/Transports/ProxyTransport.cs transport resolution + layering
Internal/Transports/HttpUpgradeHandshake.cs the shared HTTP upgrade exchange
Internal/Transports/WebSocketStream.cs RFC 6455 framing as a Stream
Internal/Crypto/Sha224.cs SHA-224/SHA-256 core (Trojan password hash, VMess KDF)
Internal/Crypto/Crc32.cs CRC-32/IEEE (VMess header checksum)
Internal/Crypto/Fnv1a32.cs FNV-1a (VMess body chunk verification)
Internal/Crypto/UuidCodec.cs big-endian UUID encoding + Xray's non-UUID id derivation
Internal/Vmess/VmessKdf.cs VMessAEAD KDF
Internal/Vmess/VmessAuthId.cs 16-byte encrypted auth id
Internal/Vmess/VmessCmdKey.cs cmdKey derivation
Internal/Vmess/VmessRequest.cs sealed request header
Internal/Vmess/VmessResponse.cs response header
Internal/Vmess/VmessResponseStream.cs lazy response-header reader
Internal/Vmess/VmessStream.cs AEAD chunk framing
REALITY authenticates by hiding a key exchange inside the TLS session_id of a
ClientHello that must look like a browser's, and SslStream hands the handshake to
Schannel or OpenSSL with no way to author those bytes. So the handshake is written
here, in QuickProxyNet/Internal/Reality/:
X25519.cs RFC 7748, because net8-net10 have no X25519 anywhere
RealityAuth.cs authKey derivation, session_id sealing, the certificate HMAC
TlsKeySchedule.cs RFC 8446 §7.1 and §7.3
TlsRecordLayer.cs Suites, record protection, record read/write
TlsWriter.cs TLS's length-prefixed vectors, with backpatching
TlsClientHello.cs The hello — NOT yet a browser fingerprint, see below
RealityTlsClient.cs The handshake state machine
RealityTlsStream.cs Application data over the record layer
VisionStream (in Internal/) carries the xtls-rprx-vision padding protocol, which
the great majority of deployed REALITY nodes require. Its TLS-in-TLS splice is not
implemented: that is throughput, not wire format.
An earlier QuickProxyNet.Reality package drove a child Xray process to do all this.
It was deleted once the managed path was proven against real servers — it was never
published, and keeping a second implementation alive to cover a shrinking gap costs
more than the gap. grpc, xhttp and a genuine uTLS fingerprint went with it; they
are listed as unsupported rather than delegated.
The managed path completes a real handshake against Xray-core and carries VLESS, with
no external process. What it is not, yet, is a fingerprint: the hello it emits has no
GREASE, no padding, an arbitrary extension order and a bare X25519 key_share, where
Chrome sends about 1.7 KB with X25519MLKEM768. That gap is a correctness problem,
not polish — a client whose hello merely works matches no deployed browser and so
puts its user in a smaller, stranger bucket than one that fails. Until it closes,
REALITY here is a protocol implementation, and the type says so in its own docs.
docs/reality-fingerprint-plan.md has the byte-level detail and the staged plan.
Testing follows the same rule as the rest of the repo — prove it against something independent:
X25519Test— RFC 7748 vectors, a keypair generated byxray x25519, and field multiplication againstBigIntegerover ~42 000 products including maximal limbs.TlsKeyScheduleTest— every value in RFC 8448's published trace.RealityAuthTest— our sealedsession_id, opened by the server algorithm transcribed fromXTLS/REALITY'stls.go.HostilePeerTest— a scripted malformed or hostile peer, in memory. This is the only suite that can reach the failure modes a cooperating server never produces. ItsKeyedServerderives real keys and a certificate bound to the REALITY auth key, which is what reaches the checks after the ServerHello. Its unbent flight is a test of its own, so a refusal there cannot be a mistake in the peer.Integration/Managed*— real handshakes and real tunnels against Xray-core, with a REALITY server whosedestpoints at a decoy TLS inbound in the same process, so nothing leaves the machine.
Strict where Go's client is strict. The server side is Go's crypto/tls, so leniency
Go's client does not have buys nothing. Application data before the server's Finished is
refused, not buffered for the stream (RFC 8446 §2; Go's readRecordOrCCS sends
unexpected_message while the handshake is incomplete, even for an empty record). Handshake
bytes still buffered when the read keys change — after the ServerHello and after the
Finished — are refused (RFC 8446 §5.1; Go's setReadTrafficSecret). A server's first
application data comes under its application keys, possibly in the same transport read as
its flight. That is safe because the record layer decrypts a record only when it is asked
for one, by which point the application keys are in place.
Public shape, decided: REALITY is reached through VlessClient — security=reality
in VlessOptions, or simply the share link via Proxy.Create(string).
Nothing under Internal/Reality/ is public except RealityHandshakeException, which is a
ProxyProtocolException so existing catch blocks see it. A separate RealityClient or a
third package were considered and rejected: a user holds a vless:// link, and the link
already says which security mode it wants.
Integration/ManagedRealityTunnelTests.PublicApi_* are the tests that go through that
public path end to end; the other Managed* tests call the handshake directly.
Every item below cost a separate investigation. Do not re-derive them, and do not "clean up" any of them without reading the reasoning first.
-
The namespace is flat. Every type is in
QuickProxyNetregardless of its folder. This is load-bearing: it lets files move between folders without a breaking API change.IDE0130(namespace must match folder) is suppressed in.editorconfigon purpose. Do not "fix" it by renaming namespaces. -
VMess and VLESS read their response header lazily.
VmessResponseStreamdecodes the sealed response header, andVlessResponseStreamvalidatesver + addonsLen, on the firstRead— not inConnectAsync. Reading either eagerly deadlocks every protocol where the client speaks first (HTTP, TLS, Minecraft): the server only flushes its header once the target has replied.VLESS was originally eager, and the byte-exact vectors could not see it —
FakeProxyStreamalways has the response already buffered.DockerProtocolTestscaught it immediately: all five VLESS cases timed out against both Xray and sing-box. A raw probe confirmed the cause: writing the VLESS request and then reading two bytes hangs on both servers, while writing the request and an HTTP GET together returns00 00followed by the HTTP response. This is why the integration tests exist — a vector proves the bytes are right, not that a server will talk to us.Consequence: a rejected VLESS handshake (wrong id — both servers just drop the connection) surfaces as a
ProxyProtocolExceptionon the firstRead, not fromConnectAsync. That is inherent to the protocol, not a regression; the server sends nothing at connect time either way. -
The VMess option byte is
0x01(ChunkStream only), not0x1D.VmessStreamimplements baseline framing. Announcing ChunkMasking / GlobalPadding / AuthenticatedLength makes the server mask chunk lengths with SHAKE128 and the stream desynchronizes immediately. -
The address-type codes differ per protocol. VLESS and VMess use
01=IPv4,02=domain,03=IPv6. Trojan and SOCKS5 use01=IPv4,03=domain,04=IPv6. Field order differs too: VLESS and VMess write the port before the address, Trojan writes it after. -
UUIDs must use
Guid.TryWriteBytes(..., bigEndian: true).Guid.ToByteArray()emits the first three fields little-endian on every platform, which is the wrong order for these wire formats. This is the classic trap;UuidCodecexists to make it impossible to hit. -
A clean VMess EOF is only an authenticated empty chunk (
00 10followed by the tag). A truncated stream or a bad tag is a hard error and must never be reported as EOF — otherwise a truncation attack looks like a normal close. -
SIMD is already done where it can be. AES-GCM, ChaCha20-Poly1305 and SHA-256 in the BCL are hardware-accelerated. The SSE4.2
crc32instruction computes CRC-32C (Castagnoli); VMess needs CRC-32/IEEE, a different polynomial, which that instruction cannot produce. FNV-1a is inherently sequential. This was measured — do not spend time on it again. -
vmess://links generally cannot beSystem.Urivalues. The base64 JSON payload exceedsUri's host-length limit and contains=padding. UseProxy.Create(string),VmessClient.FromShareLink(string)orVmessShareLink.Parse(string)— all of which operate on the raw string. -
Non-UUID user ids are real and must be derived, not rejected. Xray's
common/uuid.ParseStringmaps any id of length 1..30 toUUIDv5(nil-namespace, utf8(id))— i.e.SHA1(16 zero bytes || id)[0..16]with the version nibble set to 5 and the RFC 4122 variant bits set. Length 0 or 31 is an error; 32..36 is parsed as canonical hex.UuidCodecmirrors this exactly. About 0.3% of real-world VLESS links depend on it. -
VMess
typeis header obfuscation, and only means anything fornet=tcp. Forws/httpupgrade/grpcevery real client ignores it, so rejecting a junktypeon those transports rejects otherwise-valid links. -
vmess://has two grammars in the wild: base64-JSON (v2rayN), optionally with a#remarkfragment appended after the base64; and the standard URI formvmess://uuid@host:port?encryption=..&type=..&security=..#remark.VmessShareLinkhandles both. Roughly 55% of real links use one of the two shapes that pure base64-JSON parsing would reject. -
HTML-escaped links silently downgrade REALITY to plaintext. Some producers publish links with
&as the parameter separator. Splitting on&then yields keys namedamp;security,amp;flow,amp;pbk. Discarding them as "unknown keys" leavessecurityat its defaultNoneandflowempty, so a REALITY node passesEnsureSupported()and the client connects in the clear, sending the UUID unencrypted.ShareLinkQuery.StripHtmlAmpPrefixstrips the prefix in all three query scanners. Stripping is unconditionally safe: a literal&inside a value must be%26, so a bare&is always a separator. 68 vless and 10 trojan corpus links arrive this way, 51 REALITY.Note how this was found: not by the parse-rate number, which cannot see it — those links always "parsed successfully", just wrongly. It took connecting to live nodes. A percentage is not a proof; treat a metric that cannot fail as a metric that is not measuring.
-
httpupgrademust not sendSec-WebSocket-Key. BothwsandhttpupgradeadvertiseUpgrade: websocket— that camouflage is the whole point ofhttpupgrade— but sing-box routes any request carryingSec-WebSocket-Keyto its WebSocket handler, which an httpupgrade inbound does not have, and answers 404. Xray accepts either form. Sending the key on both looked like free camouflage and broke sing-box outright; only running both servers caught it. Measured directly: the key alone triggers the 404, whileSec-WebSocket-Versionon its own still upgrades. -
The WebSocket framing is the BCL's, on purpose.
WebSocketStreamwrapsWebSocket.CreateFromStreamrather than framing by hand. Masking, fragment reassembly and interleaved control frames are a large surface of subtle, security-relevant bugs, and that implementation is hardened and allocation-tuned. Two things the adapter must keep doing: a zero-length binary frame is not EOF (returning its0would silently truncate the tunnel — aReadmust keep going until real bytes or a close frame arrive), and message boundaries are deliberately not preserved, because a proxy tunnel is a byte stream. -
vmess://security=means two different things in the wild. The URI grammar defines it as the transport security (JSONtls), but 611 corpus links — 27% of every vmess link — put the body cipher there (JSONscy). The value sets are disjoint apart fromnone, so the reading is recovered from the value, not guessed:auto/aes-128-gcm/chacha20-poly1305are a body cipher,tls/reality/noneare transport security.nonekeeps its documented meaning — both readings agree there is no TLS, so nothing is downgraded. This is safe in a way the VLESS case was not: VMessAEAD seals the request header under a key derived from the id, so a wrong guess costs a failed handshake, never a cleartext id. -
REALITY's auth key is the TLS
key_shareprivate key. Not a second keypair smuggled somewhere — the client computesX25519(clientKeySharePrivate, pbk), and the server recovers the public half straight out ofclientHello.keyShares(the X25519 entry, or the X25519 tail of anX25519MLKEM768one). The result isHKDF-SHA256(salt: clientRandom[0..20], info: "REALITY"), and it keys an AES-256-GCM whose ciphertext plus tag exactly fill the 32-bytesession_id. The additional data is the raw ClientHello withsession_idzeroed, which is what stops a censor lifting the blob out of a recorded handshake and replaying it inside a hello of its own.session_idsits at a fixed offset 39, and only because a TLS 1.3 hello always declares a full 32-byte session id. The server then proves itself withHMAC-SHA512(authKey, leafPublicKey)placed in the leaf certificate's signature field — a fieldX509Certificate2does not expose, so the certificate has to be parsed by hand. Source:XTLS/REALITYtls.goand Xray-coretransport/internet/reality/reality.go. -
REALITY runs only over raw TCP. Xray refuses
security=realitywith awsorhttpupgradetransport outright — "REALITY only supports RAW, XHTTP and gRPC for now" — and does it at config-load time, so the failure reaches a caller as an opaque launch error rather than as anything about transports. A share link combining the two describes something no server can serve; reject it by name. -
ConnectAsyncthrows exactly three kinds of exception, and that is a contract.ProxyProtocolExceptionfor everything that can go wrong on the wire,NotSupportedExceptionfor a link describing something this library cannot speak, and theArgumentExceptionfamily for a caller's own mistake. Callers written against it catch the first and let the other two crash the process, which is right: one is a dead node, the others are a bug in the calling code.Stopping an attempt is not a failure and has its own shape. The caller's own cancellation is
OperationCanceledExceptioncarrying the caller's token, in every phase; before 5.0.0 the TCP connect reported it asConnectionFailedand the handshake threw it bare. A timeout isProxyErrorCode.Timeoutand never anOperationCanceledException. Both run through one linked token source, checked caller-first, because the caller's cancellation cancels the linked source too.Two paths used to break it, and both were invisible from inside the library — it took a checker running the public API over thousands of real nodes to see them.
CreateSocket()sat outside the guarded region in both overloads, so a bind failure or handle exhaustion escaped as a rawSocketException. AndAuthenticationExceptionderives fromSystemException, notIOException, so it slipped past theex is IOException or SocketExceptionguard — meaning an expired certificate or an unservable SNI, the most common way a TLS-carried node dies, was never reported as a proxy error at all.TlsHandshake.AuthenticateAsyncnow owns every client-side handshake so there is one place for that translation.The lesson generalises: a leak in an exception contract cannot be seen by the tests that assert on the happy path, and cannot be seen by a caller that catches
Exception. It shows up only where something classifies failures and has a bucket labelled "unrecognised" that starts filling up. -
Xray's own SOCKS inbound stalls above roughly one TLS record. A request of 16 000 bytes round-trips; 16 500 hangs until the client gives up, with no error logged by either process. Not ours, and worth remembering before spending an afternoon on it again:
LargeRequestDiagnosticTestsisolates it by carrying 100 000 bytes throughSocks5Clientagainst a plain relay and then stalling the same request against Xray with no VLESS, TLS or REALITY anywhere in the path. Disabling inbound sniffing and splitting the write into 4 KiB slices change nothing. Verified against Xray-core 26.3.27 on Windows.
- Keep hot protocol paths allocation-conscious: prefer
Span<T>,Memory<T>,ArrayPool<byte>,stackalloc, andValueTask. - Return rented buffers in
finally, and clear them when they held credentials. - Use
BinaryPrimitivesfor network byte order. - Avoid LINQ in protocol hot paths.
- Keep protocol helpers
internalunless a public API is intentionally needed. - Public API additions must have XML documentation.
- Add new proxy types through
ProxyType, client implementation, factory registration, protocol helper, error codes, and tests. - Preserve multi-target compatibility for
net8.0,net9.0,net10.0andnet11.0.net11.0is still a preview SDK, so building the repo needs a preview .NET install; that is what emitsNETSDK1057on every build. - The test project multi-targets
net10.0;net11.0so the newest target is actually exercised. A TFM nothing runs against is a claim of support, not support. - Never silently downgrade. An unrecognized
security=, a non-zeroalterId, or a transport we cannot speak must fail with a message naming what was found and what is accepted. Defaulting an unknown TLS mode to plaintext would send the user's UUID in the clear; that bug was caught in review once already.
Run the test project directly:
dotnet test QuickProxyNet.Tests/QuickProxyNet.Tests.csprojThe crypto is pinned by byte-exact vectors produced by an independent
implementation: VmessCryptoTest, VmessRequestTest, VmessBodyTest,
Sha224Test. A failure there means the code is wrong, not the test. Never relax
those vectors.
Integration tests live in QuickProxyNet.Tests/Integration/:
DockerProtocolTestsruns real Xray and sing-box servers fromtests/docker/docker-compose.yml. Enable withQPN_DOCKER_TESTS=1.ConnectTestuses external proxies viaHTTP_PROXY_URI/SOCKS5_PROXY_URI.
Both gate on environment variables through the attributes in
QuickProxyNet.Tests/SkipGates.cs ([EnvFact], [AnyEnvFact], [DockerFact],
[DockerTheory]), so an unconfigured test reports as skipped, never as
passed. Do not replace that with an early return — it turns "did not run" into
"green", which is how a test suite starts lying about what it proves.
There is no runtime Assert.Skip on xunit 2.9.3. This was checked, not
assumed. Assert.Skip / Assert.SkipWhen / Assert.SkipUnless are not in the
shipped xunit.assert 2.9.3 assembly at all — they sit behind the XUNIT_SKIP
compilation define that only xunit.v3 sets. Xunit.Sdk.SkipException.ForSkip is
public there, so throw SkipException.ForSkip(...) compiles — but the
$XunitDynamicSkip$ token it encodes appears in no v2 assembly (verified
against xunit.core 2.9.3, xunit.execution.dotnet 2.9.3 and
xunit.runner.visualstudio 3.0.0), so v2 reports the throw as a plain failure
with the raw token in the message. Discovery-time FactAttribute.Skip is the
mechanism that actually works, and environment variables do not change mid-run,
so evaluating the gate in the attribute constructor is exact.
A failed Debug.Assert fails only the test that hit it. dotnet test runs the Debug build, so
the library's asserts are live in the suite. testhost's trace listener turns a failed one into a
DebugAssertException on the asserting thread, which fails the test awaiting it; from a thread
nobody awaits, it crashes the test host and aborts the run. Asserts are for invariants that only a
bug in this library can break, never for anything a peer, a share link or a caller controls: that
must throw, because an assert is gone from the Release build and a hostile peer walks past it. And
anything that catches Exception hides a failed assert — Assert.ThrowsAny<Exception>, a catch
that only inspects a message, or a test that accepts whatever reason Proxy.TryCreate gives. Assert
the exact exception type the code throws.
If a docker run is interrupted, clean up with:
docker compose -p quickproxynet-test -f tests/docker/docker-compose.yml down -vtools/CorpusCheck runs the share-link parsers over ~20k real-world links
(PypsCFG merged_all.txt) and groups failures by reason:
dotnet run --project tools/CorpusCheck # parse the corpus
dotnet run --project tools/CorpusCheck -- --live 30 # connect to sampled live nodesRules: the corpus is downloaded to a temp directory and never committed — it
contains real IPs, UUIDs and passwords belonging to other people. Every example
in the report is redacted to a shape. Test fixtures stay synthetic. --live is
manual-only and must never run in CI.
NUKE build scripts are available from the repository root:
./build.cmd <target> # Windows
./build.sh <target> # Linux/macOSUseful targets include restore, compile, tests, pack, and push. Versioning is derived from git tags through MinVer.
- Do protocol work in sequential sub-agents. Parallel agents share the test project and break each other's build.
- Verify every agent's claims yourself:
dotnet build -c Release(0 warnings on all four TFMs) anddotnet test. Do not trust a report. - Ground truth for crypto is an independent implementation (a throwaway Python one worked well) that first reproduces the already-committed vectors, and only then is used to generate new ones.
- Follow implementation with an adversarial review round.
That process is what caught the silent plaintext downgrade on an unknown
security=, the VMess response-header deadlock, the bracketed-IPv6 host bug,
and credential buffers that were returned to the pool unzeroed.
Notes for VLESS, VMess and Trojan live in docs/ alongside the implementation.
docs/quic-protocols-analysis.md covers Hysteria2 and TUIC: those are not
implemented, and that document explains the architectural problem (one QUIC
connection multiplexes many streams, which does not fit "one ConnectAsync, one
socket") that has to be solved before they can be.
Measured by this library's own parsers over 20 228 real links (2026-08-21), counting what can actually connect, not what parses. REALITY — 46% of the corpus — is done and in-process as of 4.0.0; what remains:
| Blocker | % of corpus |
|---|---|
| gRPC transport (needs an HTTP/2 layer) | 6.0% |
| xhttp transport (HTTP/2/3, Xray-only) | 2.9% |
| Hysteria2 / TUIC (QUIC) | 2.9% |
And one thing that is not a blocker but matters more than any of those: the REALITY
ClientHello is still not a browser fingerprint. It connects — verified against live
nodes — but a DPI that fingerprints hellos can tell it from Chrome. The staged plan is
in docs/reality-fingerprint-plan.md; it is the next REALITY work, ahead of any new
transport.
The point of the table: QUIC is the worst remaining investment. It is the heaviest
architectural work — it breaks the "one ConnectAsync, one socket" model — and buys
under 3%. gRPC is the cheapest of the three and unlocks the most.