Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6e4fdef
make the API usable in bulk and close two holes in the exception cont…
Titlehhhh Sep 6, 2026
7feb18a
feat: speak Shadowsocks AEAD over TCP
Titlehhhh Sep 8, 2026
4b23f48
fix(shadowsocks): stop reading a bare password as a cipher name
Titlehhhh Sep 9, 2026
d26832f
fix(http): bracket an IPv6 literal target in CONNECT
Titlehhhh Sep 14, 2026
1d9f4b1
fix: reach a proxy at an IPv6 address
Titlehhhh Sep 14, 2026
e43e8e4
fix(https): check the proxy's certificate against the proxy's name
Titlehhhh Sep 14, 2026
3b1c993
feat: take the connect target as an EndPoint
Titlehhhh Sep 14, 2026
850a1c3
fix(socks): keep an over-long string inside the exception contract
Titlehhhh Sep 14, 2026
09ba561
fix(http): clear the pooled buffers that held credentials
Titlehhhh Sep 14, 2026
11c1f81
fix: stop leaving tunnel bytes in pooled arrays on synchronous span I/O
Titlehhhh Sep 14, 2026
5065bec
fix(vmess): an '@' in the remark no longer rejects a base64 link
Titlehhhh Sep 14, 2026
2c406cf
fix: decode escaped credentials in a classic proxy link
Titlehhhh Sep 14, 2026
539ac05
feat!: take Uri out of the public API
Titlehhhh Sep 14, 2026
a8e7420
fix: one connect path, with the timeout as a cancellation
Titlehhhh Sep 14, 2026
491244d
perf(reality): expand HKDF as its single block
Titlehhhh Sep 14, 2026
ef545d3
perf: stop boxing async state machines in the tunnel streams
Titlehhhh Sep 14, 2026
5df4932
refactor(transport): Ascii.EqualsIgnoreCase and span Trim for upgrade…
Titlehhhh Sep 14, 2026
cdc03da
fix(https): let SslStream's own check say why a certificate is refused
Titlehhhh Sep 14, 2026
f18e45e
build: turn on the trim and AOT analyzers
Titlehhhh Sep 14, 2026
9aec0e3
refactor: throw helpers for argument checks, and a timeout doc that w…
Titlehhhh Sep 14, 2026
8815e6f
fix: decode share-link base64 the same on .NET 10 and .NET 11
Titlehhhh Sep 14, 2026
8e6166a
fix(vless): refuse an undecodable REALITY key or short id before conn…
Titlehhhh Sep 14, 2026
9186eeb
feat!: remove ReadTimeout and WriteTimeout
Titlehhhh Sep 14, 2026
234968f
fix(reality): be as strict as Go's client around the server's Finished
Titlehhhh Sep 14, 2026
969b0d6
fix(socks): size the request buffer for the largest SOCKS4a request
Titlehhhh Sep 14, 2026
1e33046
fix: refuse control characters in a target host, and a NUL in a SOCKS…
Titlehhhh Sep 14, 2026
6632200
fix(reality): bound the server name and ALPN list where the configura…
Titlehhhh Sep 14, 2026
fbb0cf8
fix: refuse control characters in a ws/httpupgrade request and in a p…
Titlehhhh Sep 14, 2026
ad3d356
chore: assert the internal invariants the stream and record code reli…
Titlehhhh Sep 14, 2026
a7ae68e
fix: report a socks link without a host as malformed, as an http one …
Titlehhhh Sep 14, 2026
624435b
test: assert the exact exception where a catch-all would pass a faile…
Titlehhhh Sep 14, 2026
0fcbe7e
docs: say what a failed Debug.Assert does under dotnet test, and what…
Titlehhhh Sep 14, 2026
1acdbad
chore: store SocksHelper.cs as text again
Titlehhhh Sep 14, 2026
e88d998
fix(http): clear the response buffer before returning it to the pool
Titlehhhh Sep 30, 2026
dd8a178
docs: package notes and READMEs for 5.0.0
Titlehhhh Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 71 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,25 @@ hand-run diagnostic, and keeping it out of the solution keeps it out of CI.

All public library types live in the `QuickProxyNet` namespace.

- `Proxy` exposes static one-call `ConnectAsync(...)` helpers.
- `ProxyUriExtensions` adds `Uri.ConnectThroughProxyAsync(...)`.
- `Proxy` is the static entry point: one-call `ConnectAsync(...)` helpers, plus
`Create(...)` / `TryCreate(...)` building a client from a share-link `string`,
from a `Uri`, or from explicit proxy settings.
- There are no `Uri` connect overloads and no `IProxyClient.ProxyUri` (removed in
5.0.0). A `Uri` cannot hold most `vmess://` 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 for `WebProxy.Address` and
`IWebProxy.GetProxy`; `ProxyClient.ToString()` is `scheme://host:port` for logs.
- There is no `IProxyClient.ReadTimeout` / `WriteTimeout` (removed in 5.0.0). They were
copied to `Socket.ReceiveTimeout` / `SendTimeout`, which bind only synchronous calls, so
they never applied to a handshake. Do not bring them back: the `TimeSpan` overloads of
`ConnectAsync` bound the handshake, and a caller bounds reads on the returned stream.
- `IProxyClient` is the client contract; connection methods return
`ValueTask<Stream>`.
`ValueTask<Stream>`. `SourceLink` carries the text the client was built from.
A target is `host, port` or an `EndPoint` (`DnsEndPoint` / `IPEndPoint`); the
`EndPoint` overloads are default interface members that forward to the
host-and-port ones, so an implementation outside `ProxyClient` gets them free.
- `ProxyClient` owns common socket setup, timeout handling, and argument
validation.
- `ProxyClientFactory` creates clients from a share-link `string`, from a `Uri`,
or from explicit proxy settings.
- `ProxyProtocolException` carries a structured `ProxyErrorCode`.
- `VlessOptions` / `TrojanOptions` / `VmessOptions` plus the matching
`*ShareLink.Parse` / `TryParse` describe a VPN-style endpoint.
Expand Down Expand Up @@ -141,12 +152,25 @@ independent:
transcribed from `XTLS/REALITY`'s `tls.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.
Its `KeyedServer` derives 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 whose `dest` points 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 `ProxyClientFactory.Create(string)`.
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
Expand Down Expand Up @@ -213,7 +237,7 @@ not "clean up" any of them without reading the reasoning first.

8. **`vmess://` links generally cannot be `System.Uri` values.** The base64 JSON
payload exceeds `Uri`'s host-length limit and contains `=` padding. Use
`ProxyClientFactory.Create(string)`, `VmessClient.FromShareLink(string)` or
`Proxy.Create(string)`, `VmessClient.FromShareLink(string)` or
`VmessShareLink.Parse(string)` — all of which operate on the raw string.

9. **Non-UUID user ids are real and must be derived, not rejected.** Xray's
Expand Down Expand Up @@ -297,7 +321,36 @@ not "clean up" any of them without reading the reasoning first.
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.

18. **Xray's own SOCKS inbound stalls above roughly one TLS record.** A request of
18. **`ConnectAsync` throws exactly three kinds of exception, and that is a contract.**
`ProxyProtocolException` for everything that can go wrong on the wire,
`NotSupportedException` for a link describing something this library cannot speak,
and the `ArgumentException` family 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 `OperationCanceledException` carrying the caller's token, in every
phase; before 5.0.0 the TCP connect reported it as `ConnectionFailed` and the
handshake threw it bare. A timeout is `ProxyErrorCode.Timeout` and never an
`OperationCanceledException`. 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 raw `SocketException`. And
`AuthenticationException` derives from `SystemException`, not `IOException`, so it
slipped past the `ex is IOException or SocketException` guard — 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.AuthenticateAsync` now 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.

19. **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: `LargeRequestDiagnosticTests` isolates it by carrying
Expand Down Expand Up @@ -366,6 +419,16 @@ 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:

```bash
Expand Down
4 changes: 2 additions & 2 deletions QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,15 @@ public void Setup()
{
Write = new TlsRecordProtection(suite, sinkSecret)
};
_sink = new RealityTlsStream(drainTransport, sinkRecords, []);
_sink = new RealityTlsStream(drainTransport, sinkRecords);

var echoTransport = new NetworkStream(_echo.Client, ownsSocket: false);
var clientRecords = new TlsRecordStream(echoTransport)
{
Write = new TlsRecordProtection(suite, pairSecret),
Read = new TlsRecordProtection(suite, pairSecret)
};
_client = new RealityTlsStream(echoTransport, clientRecords, []);
_client = new RealityTlsStream(echoTransport, clientRecords);
}

[GlobalCleanup]
Expand Down
6 changes: 3 additions & 3 deletions QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public void Setup()
{
Write = new TlsRecordProtection(suite, sinkSecret)
};
_sink = new RealityTlsStream(Stream.Null, sinkRecords, []);
_sink = new RealityTlsStream(Stream.Null, sinkRecords);

// Room for 1 MiB of plaintext plus per-record headers and tags, so the stream never grows
// during a measured operation.
Expand All @@ -91,8 +91,8 @@ public void Setup()
Read = new TlsRecordProtection(suite, pairSecret)
};

_writer = new RealityTlsStream(_wire, writerRecords, []);
_reader = new RealityTlsStream(_wire, readerRecords, []);
_writer = new RealityTlsStream(_wire, writerRecords);
_reader = new RealityTlsStream(_wire, readerRecords);
}

[GlobalCleanup]
Expand Down
Loading
Loading