diff --git a/Cargo.lock b/Cargo.lock index a7d9520..acd35b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,6 +157,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", + "cipher", "cpufeatures", "rand_core", ] @@ -172,6 +173,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer", + "crypto-common", + "inout", +] + [[package]] name = "cmov" version = "0.5.4" @@ -704,6 +716,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "instability" version = "0.3.13" @@ -865,10 +886,11 @@ dependencies = [ [[package]] name = "nowhere" -version = "1.8.3" +version = "2.0.0-dev" dependencies = [ "anyhow", "bytes", + "chacha20", "chrono", "crossterm", "getrandom 0.4.3", diff --git a/Cargo.toml b/Cargo.toml index 5f3031b..0215888 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "nowhere" -version = "1.8.3" +version = "2.0.0-dev" edition = "2024" -description = "One-port, two-transport encrypted relay with independently split directions" +description = "Two-transport encrypted relay with independently split directions" license = "GPL-3.0-only" repository = "https://github.com/NodePassProject/Nowhere" readme = "README.md" @@ -10,6 +10,7 @@ readme = "README.md" [dependencies] anyhow = "1.0.104" bytes = "1.12.1" +chacha20 = "0.10.1" chrono = { version = "0.4.45", default-features = false, features = ["clock"] } crossterm = "0.29.0" getrandom = "0.4.3" diff --git a/README.md b/README.md index a35caa9..df2fa36 100644 --- a/README.md +++ b/README.md @@ -3,48 +3,34 @@

- One port. Two transports. Split directions. + One relay. Two carriers. Independent directions.

- A cross-platform encrypted relay that composes TLS/TCP and QUIC/UDP
- independently for upload and download. + A cross-platform relay that composes TLS/TCP and QUIC/UDP
+ independently for every flow.

- Live operations · Architecture · Quick start · + Live operations · Documentation · Wire protocol

-Nowhere gives one service edge two encrypted carrier families. A local -**Vector** accepts SOCKS5 traffic; a remote **Portal** authenticates carriers, -opens targets, and relays data. Every logical flow chooses its uplink and -downlink independently instead of forcing both directions onto one transport. +Nowhere joins TLS/TCP and QUIC/UDP behind one service edge. **Vector** accepts +local SOCKS5 traffic; **Portal** authenticates carriers and reaches the target. +Each flow selects its uplink and downlink independently. | Core property | What it means | | --- | --- | -| One service edge | TLS/TCP and QUIC/UDP share one address, port number, credential, and lifecycle | -| Split directions | Uplink and downlink independently select TLS/TCP or QUIC/UDP | -| Complete ingress | SOCKS5 CONNECT carries TCP; UDP ASSOCIATE carries UDP | -| Native chaining | A Portal can forward directly to another Portal without a loopback SOCKS5 conversion | -| Local observability | The same binary discovers running instances and renders live telemetry metrics | - -## Live operations - -

- Nowhere TUI showing live traffic histories, connection and carrier metrics, privacy-aware access logs, runtime events, filtering, pause, and help -

- -The read-only TUI discovers Portal and Vector instances for the current user. -It presents traffic, carriers, process metrics, Access logs, and Runtime logs -without owning the service lifecycle. Start it from another terminal: - -```bash -nowhere tui -``` +| Unified edge | TLS/TCP and QUIC/UDP share one identity and lifecycle | +| Split routing | Uplink and downlink choose their carrier independently | +| Optional Morph | A keyed transform masks the TLS/QUIC wire image | +| TCP and UDP | SOCKS5 CONNECT and UDP ASSOCIATE are both supported | +| Native chaining | Portal forwards directly to Portal with no local proxy loop | +| Built-in telemetry | The same binary discovers and inspects live instances | ## How it works @@ -68,13 +54,24 @@ nowhere tui +------------+ +------------+ ``` -Portal defaults to `net=mix`, accepting both carrier families on the same port -number. `net=tcp` and `net=udp` intentionally restrict the listener when an -operator wants only one carrier family. +Each service URL uses either a compact endpoint for both carriers on one port, +or an explicit endpoint that assigns carriers, ports, and address families. + +| Endpoint | Meaning | +|---|---| +| `@*:2000` | TLS/TCP and QUIC/UDP wildcard candidates, port 2000 | +| `@*/tcp:2006` | TLS/TCP only, IPv4 and IPv6 | +| `@*/udp:2017` | QUIC/UDP only, IPv4 and IPv6 | +| `@*/tcp4:2006/udp6:2017` | TLS/TCP on IPv4 and QUIC/UDP on IPv6 | -### One flow, two transport decisions +`*` is reserved for Portal listeners; Vector and `next` require a concrete +address or hostname. On Portal, `@:2000` is shorthand for `@*:2000`. The full +grammar is documented in [Configuration](docs/configuration.md). -Vector's `up` and `down` parameters accept `tcp`, `udp`, or `mix`: +### Independent uplink and downlink + +`up` and `down` accept `tcp`, `udp`, or `mix`. With both carriers available, +the default is TCP; `mux=1` enables TLS multiplexing. | `up` ↓ / `down` → | `tcp` | `udp` | `mix` | |---|---|---|---| @@ -82,37 +79,63 @@ Vector's `up` and `down` parameters accept `tcp`, `udp`, or `mix`: | `udp` | QT | QQ | QT ↔ QQ | | `mix` | TT ↔ QT | TQ ↔ QQ | TT ↔ QQ | -T means TLS/TCP and Q means QUIC/UDP, with uplink first. Each mixed cell makes -one stateless 50/50 choice per flow; `mix/mix` produces only TT or QQ. The -primary route has a `NOW_MIX_FALLBACK_TIMEOUT` budget (default `1s`), then the -other route is attempted once with a new flow ID. FlowHeader carries only the -resolved concrete pair, and no fallback occurs after its write begins. Portal -`next=` applies the same policy independently per hop. +T is TLS/TCP and Q is QUIC/UDP, with uplink first. `mix` makes one 50/50 choice +per flow and may try the alternate route once before commitment. Portal +`next=` applies the same policy independently on each hop. + +## Data path -## Engineered for a small data path +Authentication belongs to each physical carrier; routing belongs to each +logical flow. Once Portal returns `READY`, application data travels as a plain +byte stream or QUIC DATAGRAM payload. -The data path uses compact binary frames, connection-bound authentication, -reusable buffers, bounded queues, and native QUIC streams and DATAGRAMs. TLS -flows use dedicated lanes or lazily opened Mux Shards. Detailed framing and -resource bounds live in [Protocol](docs/protocol.md) and -[Security](docs/security.md). +```text +Carrier bootstrap Logical flow + ++----------------+ +----------------+----------+-------------+ +| AuthFrame | | FlowHeader | Target? | Payload ... | +| 32 bytes | | 5 bytes | variable | after READY | ++----------------+ +----------------+----------+-------------+ + | | + +-- TLS: dedicated lane or Mux +-- TCP: reliable byte stream + +-- QUIC: first stream only +-- UDP: UoT or QUIC DATAGRAM +``` -### Native Portal chaining +Frames are compact, DATA payload queues are bounded by byte credit, and hot-path +buffers are reused. See +[Protocol](docs/protocol.md) for the wire contract and +[Security](docs/security.md) for trust boundaries. -A relay Portal can terminate the incoming TLS/QUIC carrier and open the next -Nowhere flow directly with the same transport engine used by Vector: +### Morph + +`morph=1` masks the bare TLS/QUIC wire image with a transform derived from the +shared key: + +```text +TCP client -> server [ nonce 12B ][ ChaCha20-XOR(TLS stream) ] + server -> client [ ChaCha20-XOR(TLS stream) ] + +UDP each datagram [ nonce 12B ][ ChaCha20-XOR(QUIC datagram) ] +``` + +Both endpoints on a hop must enable it. Morph is wire masking, with no protocol +camouflage or added security semantics. See [Protocol](docs/protocol.md). + +### Native chaining + +A Portal can open the next Nowhere hop directly: ```bash nowhere \ - 'portal://relay-key@:2077?next=origin-key@origin.example:2077&up=udp&down=udp' + 'portal://relay-key@:2000?next=origin-key@origin.example:2000&up=udp&down=udp' ``` -`next` is lazy and mutually exclusive with outbound `socks`. Portal forwarding -uses the native flow protocol and is bounded to seven hops. +`next` is lazy, mutually exclusive with outbound `socks`, and bounded to seven +hops. ## Quick start -Building from source requires a supported target and a stable Rust toolchain. +Use a stable Rust toolchain on a supported target. ### 1. Build @@ -122,62 +145,68 @@ cargo build --release --locked ### 2. Start Portal -The default `net=mix` mode accepts TLS/TCP and QUIC/UDP on port `2077`: +Listen on TLS/TCP and QUIC/UDP at port `2000`: ```bash -./target/release/nowhere 'portal://change-me@127.0.0.1:2077' +./target/release/nowhere 'portal://change-me@127.0.0.1:2000' ``` ### 3. Start Vector -This Vector exposes SOCKS5 on `127.0.0.1:1080`: +Expose SOCKS5 on `127.0.0.1:1080`: ```bash ./target/release/nowhere \ - 'vector://change-me@127.0.0.1:2077?up=tcp&down=tcp&socks=127.0.0.1:1080' + 'vector://change-me@127.0.0.1:2000?up=tcp&down=tcp&socks=127.0.0.1:1080' ``` -Mux, split-carrier, certificate, and chaining examples are in the -[configuration guide](docs/configuration.md) and -[quick start](docs/quick-start.md). +More examples are available in [Configuration](docs/configuration.md) and the +[extended quick start](docs/quick-start.md). ### 4. Inspect -Open another terminal and run: +Open the local TUI from another terminal: ```bash ./target/release/nowhere tui ``` -## Before public deployment +## Live operations + +

+ Nowhere TUI showing live traffic histories, connection and carrier metrics, privacy-aware access logs, runtime events, filtering, pause, and help +

+ +The read-only TUI discovers local Portal and Vector instances and presents +traffic, carrier, process, and log data without controlling their lifecycle. -The local examples omit `sni`, which disables certificate verification. A -public Portal should use a CA-trusted certificate with strict verification: +## Public deployment + +The local examples disable certificate verification by omitting `sni`. Public +deployments should use a trusted certificate and verified server name: ```bash -nowhere 'portal://change-me@:2077?tls=2&crt=/etc/nowhere/cert.pem&key=/etc/nowhere/key.pem' -nowhere 'vector://change-me@relay.example:2077?sni=relay.example&socks=127.0.0.1:1080' +nowhere 'portal://change-me@:2000?tls=2&crt=/etc/nowhere/cert.pem&key=/etc/nowhere/key.pem' +nowhere 'vector://change-me@relay.example:2000?sni=relay.example&socks=127.0.0.1:1080' ``` -Certificate pinning is also available. Review the -[security model](docs/security.md) and [configuration](docs/configuration.md) -before exposing a Portal publicly. +Certificate pinning is also available. Review [Security](docs/security.md) and +[Configuration](docs/configuration.md) before exposing a Portal. -## Operational boundaries +## Platform scope -Portal, Vector, relay, TUI, and local discovery run on every supported -platform; process telemetry varies by operating system. See -[Platforms](docs/platforms.md) and [Operations](docs/operations.md). +Portal, Vector, relay, TUI, and discovery share the supported platform matrix; +process telemetry varies by operating system. See [Platforms](docs/platforms.md) +and [Operations](docs/operations.md). -## Documentation map +## Documentation -Start with the [documentation index](docs/README.md). It links the focused -guides for configuration, protocol, security, operations, platforms, and -integrations. +The [documentation index](docs/README.md) covers configuration, protocol, +security, operations, platforms, and integrations. ## Development -Run the project checks on a supported host: +Run the standard checks on a supported host: ```bash cargo fmt --all -- --check @@ -186,25 +215,20 @@ cargo clippy --all-targets --locked -- -D warnings cargo build --release --locked ``` -On macOS with [Apple Container](https://github.com/apple/container), the -reusable Linux check environment remains available: +On macOS, [Apple Container](https://github.com/apple/container) provides the +reusable Linux check environment: ```bash ./scripts/check-linux.sh ``` -CI runs the project on Linux, macOS, and Windows. Release packaging covers -Linux GNU/musl on x86-64 and AArch64, macOS on Apple Silicon, and Windows -x86-64 MSVC. - -Protocol changes must update the normative wire document and protocol-vector -tests in the same change. +CI covers Linux, macOS, and Windows. Release packaging covers Linux GNU/musl on +x86-64 and AArch64, macOS on Apple Silicon, and Windows x86-64 MSVC. Protocol +changes must update the wire document and protocol vectors together. ## License Nowhere is licensed under the [GNU General Public License v3.0](LICENSE). -Distributions of original or modified binaries must comply with the GPLv3 -source and notice requirements. --- diff --git a/assets/nowhere.png b/assets/nowhere.png index 4eaac74..79bb4c3 100644 Binary files a/assets/nowhere.png and b/assets/nowhere.png differ diff --git a/docs/README.md b/docs/README.md index c3e95d1..712386c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,20 +1,21 @@ # Documentation -The documentation has one source of truth for each concern: +Each document owns one part of the Nowhere interface. | Need | Document | |---|---| -| Run a local Portal and Vector | [Quick start](quick-start.md) | -| Choose and operate a supported platform | [Platforms](platforms.md) | -| Understand URL and environment options | [Configuration](configuration.md) | -| Implement or inspect the wire format | [Protocol](protocol.md) | -| Deploy and observe the processes | [Operations](operations.md) | -| Review authentication and memory bounds | [Security](security.md) | -| Understand ALPN and peer interoperability | [Interoperability](compatibility.md) | -| Implement another client or integration | [Integrations](integrations.md) | - -`protocol.md` is normative. Portal and Vector share one internal bounded TLS -Mux engine. +| Start a local Portal and Vector | [Quick start](quick-start.md) | +| Configure URLs and runtime behavior | [Configuration](configuration.md) | +| Deploy and observe processes | [Operations](operations.md) | +| Choose a supported system | [Platforms](platforms.md) | +| Review authentication and resource bounds | [Security](security.md) | +| Understand peer and carrier contracts | [Interoperability](interoperability.md) | +| Implement the wire format | [Protocol](protocol.md) | +| Connect another client or service | [Integrations](integrations.md) | + +[Configuration](configuration.md) defines command URLs and runtime settings. +[Protocol](protocol.md) defines bytes exchanged between peers. The other guides +describe how those interfaces are used and operated. Portal and Vector have the same transport behavior on Linux, macOS, and Windows. Platform-specific packaging, process control, filesystem paths, and @@ -54,6 +55,34 @@ Each Portal chooses exactly one outbound path for a flow: direct target access, an outbound SOCKS5 proxy, or a native `next` Portal. The carrier choice on one hop does not constrain the carrier choice on another hop. +## Endpoint summary + +Portal listeners, Vector remote endpoints, and Portal `next` endpoints use the +same carrier grammar: + +```text +HOST:PORT +HOST/CARRIER:PORT[/CARRIER:PORT] +``` + +The compact form declares TLS/TCP and QUIC/UDP on one port. The explicit form +declares only its listed carriers. `tcp` and `udp` accept IPv4 and IPv6; +`tcp4`, `udp4`, `tcp6`, and `udp6` restrict the address family. Both carriers +share `HOST`, while their ports and address families remain independent. + +| Role | Host rule | Endpoint result | +|---|---|---| +| Portal | `*`, IP literal, hostname, or compact empty host | Opens every declared listener | +| Vector | IP literal or hostname | Dials only the declared remote carriers | +| Portal `next` | IP literal or hostname | Uses the same client engine as Vector | + +`portal://key@:2000` is the compact alias for +`portal://key@*:2000`. Vector and `next` reject `*`. A Portal resolves listener +hostnames once at startup and binds every matching address; clients resolve and +filter each carrier by its declared family. Configuration errors stop startup +before service traffic is accepted. The full syntax and error rules are in +[Configuration](configuration.md). + ## Protocol summary | Client Mux setting | TLS/TCP | QUIC/UDP | Failure scope | @@ -61,8 +90,8 @@ on one hop does not constrain the carrier choice on another hop. | `mux=0` | Dedicated lane per flow | Native streams/datagrams | One flow per carrier | | `mux=1` | Shared bounded Mux | Native streams/datagrams | Assigned flows close with the carrier | -ALPN defaults to `now/1` and is configurable independently from Mux. Peers use -the same exact ALPN. The client-side route-policy matrix is: +Nowhere 2 peers negotiate the fixed `nw2` ALPN. Peers without `nw2` cannot +complete a Nowhere 2 carrier handshake. The client-side route-policy matrix is: | `up` ↓ / `down` → | `tcp` | `udp` | `mix` | |---|---|---|---| diff --git a/docs/compatibility.md b/docs/compatibility.md deleted file mode 100644 index 2d29a38..0000000 --- a/docs/compatibility.md +++ /dev/null @@ -1,69 +0,0 @@ -# Interoperability - -## ALPN contract - -Portal and Vector advertise one exact TLS 1.3 ALPN. The default is `now/1`; -`alpn=` selects another nonempty value up to 255 bytes. Peers must use -the same value for TLS/TCP and QUIC/UDP. ALPN does not select a protocol version -or enable Mux. - -## TLS lane contract - -Vector `mux=0` opens one authenticated TLS connection per Flow. Vector `mux=1` -opens marked Mux connections and assigns logical streams to dynamic Shards. - -Portal accepts both forms on one listener. After the 32-byte authentication -frame: - -- `0xff` identifies a Mux connection; -- every other byte is the first byte of a dedicated FlowHeader. - -The marker cannot collide with a valid FlowHeader. Dedicated and marked Mux -connections use the same listener without separate inbound configuration. -An authenticated dedicated connection has 40 seconds to provide its first -FlowHeader byte. - -```text - first byte after AuthFrame - | - +-----------------+-----------------+ - | | - 0xff any other byte - | | - v v - +--------------------+ +--------------------+ - | Mux frame decoder | | FlowHeader decoder | - | shared TLS carrier | | dedicated TLS lane | - +--------------------+ +--------------------+ -``` - -Portal dispatches every authenticated TLS connection by its framing: - -| Bytes after AuthFrame | Selected form | Result | -|---|---|---| -| Valid FlowHeader | Dedicated TLS | accepted | -| `0xff`, then valid Mux frames | Marked Mux TLS | accepted | -| Unmarked Mux bytes | Invalid FlowHeader | rejected | - -The `0xff` byte is the Mux mode marker. It is always present on a Mux carrier -and never appears on a dedicated lane. - -## Runtime contract - -Mux Shards open lazily at 4 active flows, select the least-loaded live Shard, -and close after 30 seconds fully idle. Dedicated lanes and Mux streams use the -same authentication, FlowHeader, Target, setup result, pairing and limits. -QUIC behavior is independent from the client Mux setting. - -Peers must also use matching credentials and reachable carrier families. A -Portal with `next=` uses its configured ALPN and the same `tcp|udp|mix` policy -as Vector for the next hop. Mix resolves locally before transmission, and the -peer receives a standard TT, TQ, QT, or QQ FlowHeader. Portal compatibility is -therefore independent of whether the client URL uses a fixed or mixed policy. -The upstream Mux selection defaults to `0`, is ignored without an enabled -`next`, and canonicalizes to `0` for a fixed `udp/udp` route. - -Interoperability tests exercise both peer roles: one endpoint as Portal and the -other as client. The complete 3×3 `up`/`down` policy matrix covers all four -concrete routes and all five policies containing `mix`, together with the -default and a custom ALPN, dedicated TLS, and marked Mux. diff --git a/docs/configuration.md b/docs/configuration.md index d2ba5b3..41c24e1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,67 +4,198 @@ URLs and environment variables have the same meaning on Linux, macOS, and Windows. Shell quoting and filesystem path syntax follow the host platform; see [Platforms](platforms.md). +## Service endpoint grammar + +Portal, Vector, and native Portal chaining share one endpoint model: + +```text +portal://KEY@HOST:PORT[?QUERY] +portal://KEY@HOST/CARRIER:PORT[/CARRIER:PORT][?QUERY] + +vector://KEY@HOST:PORT?QUERY +vector://KEY@HOST/CARRIER:PORT[/CARRIER:PORT]?QUERY + +next=KEY@HOST:PORT +next=KEY@HOST/CARRIER:PORT[/CARRIER:PORT] +``` + +The outer URL remains a standard URL. `KEY` is URL userinfo, `HOST` is the URL +host, and each `CARRIER:PORT` is a path segment. RFC 3986 defines the standard +[authority](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2) and allows +the colon in a [path segment](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.3), +so the explicit form does not replace or extend URL authority grammar. + +| Form | Enabled carriers | Ports | Address-family policy | +|---|---|---|---| +| `HOST:PORT` | TCP and UDP | Shared | Unrestricted | +| `HOST/tcp:PORT` | TCP only | TCP port | Unrestricted | +| `HOST/udp:PORT` | UDP only | UDP port | Unrestricted | +| `HOST/tcp:PORT/udp:PORT` | TCP and UDP | Independent | Unrestricted | +| `HOST/tcp4:PORT/udp6:PORT` | TCP and UDP | Independent | TCP IPv4, UDP IPv6 | + +Carrier names have the same meaning in every role: + +| Carrier | Transport | Accepted address family | +|---|---|---| +| `tcp` | TLS over TCP | IPv4 and IPv6 | +| `tcp4` | TLS over TCP | IPv4 only | +| `tcp6` | TLS over TCP | IPv6 only | +| `udp` | QUIC over UDP | IPv4 and IPv6 | +| `udp4` | QUIC over UDP | IPv4 only | +| `udp6` | QUIC over UDP | IPv6 only | + +`tcp` and `udp` mean that the endpoint does not restrict the address family. +They do not require both families to exist on the host. An IP literal narrows +an unrestricted carrier naturally; an explicit suffix that conflicts with the +literal is invalid. + +Both carriers always share `HOST`. Use separate service URLs when TCP and UDP +must use different IP addresses or hostnames. The explicit path controls which +carriers exist, so an omitted carrier is disabled rather than assigned a +default port. + +Canonical output lists TCP before UDP regardless of input order. It uses the +compact form when both carriers are unrestricted and use the same port; +otherwise it prints the explicit path. Effective configuration, logs, and the +TUI use this normalized endpoint and omit the shared key. + ## Portal URL ```text -portal://shared-key@host:port?net=mix&tls=1&log=info +portal://shared-key@host:port?tls=1&log=info +portal://shared-key@*:2000?tls=1&log=info +portal://shared-key@*/tcp:2006/udp:2017?tls=1&log=info +portal://shared-key@host/tcp4:2006/udp6:2017?tls=1&log=info +portal://shared-key@*:2000?tls=1&morph=1&log=info ``` +The compact `host:port` form enables TLS/TCP and QUIC/UDP on the same port. +The explicit path enables only the listed carriers. `tcp` and `udp` accept +either address family; suffix `4` or `6` to restrict that carrier. Each carrier +may appear at most once. + +Both carriers share the host; their ports and address families are independent. +The wildcard `*` expands to separate IPv4 and IPv6 sockets, with IPv6 sockets +set to `V6ONLY`. Hostnames resolve at startup to all matching, deduplicated +addresses; listeners do not refresh DNS while running. + +An unrestricted wildcard listener can omit an unavailable address family with +a warning. Each declared carrier must bind at least one address. Explicit +address families, concrete addresses, occupied ports, and permission failures +cause startup to fail and release the listeners already opened. + +The Portal host controls binding: + +| Host | Listener behavior | +|---|---| +| empty in compact form | Alias for `*` | +| `*` | Separate wildcard sockets for every permitted address family | +| IPv4 literal | Bind that IPv4 address | +| bracketed IPv6 literal | Bind that IPv6 address | +| hostname | Resolve once and bind every matching, deduplicated address | + +IPv6 TCP and UDP listeners set `V6ONLY`, including wildcard listeners. A +dual-stack wildcard therefore consists of distinct `0.0.0.0` and `[::]` +sockets instead of relying on an operating-system dual-stack default. + | Query | Values | Default | |---|---|---| -| `net` | `mix`, `tcp`, `udp` | `mix` | | `tls` | `1` generated certificate, `2` supplied certificate | `1` | | `crt`, `key` | PEM paths, required with `tls=2` | — | -| `alpn` | exact TLS/QUIC ALPN, 1–255 bytes | `now/1` | | `rate`, `etar` | Mbps, `0` disables limit | `0` | | `dial` | `auto` or local IP | `auto` | +| `morph` | `0` bare TLS/QUIC wire, `1` keyed wire transform | `0` | | `socks` | outbound SOCKS5 configuration | disabled | -| `next` | `shared-key@host:port` | disabled | -| `up`, `down` | native next-hop policy: `tcp`, `udp`, or `mix` | `udp` | +| `next` | `shared-key@host:port` or explicit carrier endpoint | disabled | +| `up`, `down` | native next-hop policy: `tcp`, `udp`, or `mix` | only carrier, otherwise `tcp` | | `mux` | native next-hop TLS: `0` dedicated lanes, `1` Mux when TCP is possible | `0` | | `sni` | native next-hop verified DNS name, or `none` | `none` | | `pin` | native next-hop certificate SHA-256 pin, or `none` | `none` | | `log` | `none`, `debug`, `info`, `warn`, `error`, `event` | `info` | When `next` is enabled, `up`, `down`, `mux`, `sni`, and `pin` configure that -upstream hop. The Portal's `alpn` also applies to its native upstream client. -These upstream options are ignored when `next` is absent or `none`. +upstream hop. Protocol version is negotiated independently with the next +Portal. These upstream options are ignored when `next` is absent or `none`. `socks` and `next` are mutually exclusive outbound paths. +`morph=1` controls both the Portal listener and its native `next` client. The +listener derives Morph keys from the outer Portal key; the `next` client derives +them from the key inside `next`. The nested value never carries an inner query. + ## Vector URL ```text vector://shared-key@host:port?up=tcp&down=tcp&socks=127.0.0.1:1080 +vector://shared-key@host/tcp:2006?socks=127.0.0.1:1080 +vector://shared-key@host/udp6:2017?socks=127.0.0.1:1080 +vector://shared-key@host/tcp:2006/udp:2017?up=tcp&down=udp&socks=127.0.0.1:1080 +vector://shared-key@host:2000?morph=1&socks=127.0.0.1:1080 ``` +Vector uses the TCP carrier port only for TLS and the UDP carrier port only for +QUIC. Hostname results are filtered independently for each carrier. `tcp4` and +`udp4` never fall through to IPv6, and `tcp6` and `udp6` never fall through to +IPv4. If no resolved address matches the selected family, dialing fails with a +configuration-specific address error. + +When an endpoint declares one carrier, omitted `up` and `down` both select that +carrier. When both carriers exist, each omitted direction selects TCP. An +explicit direction may select only a declared carrier, and `mix` requires both +TCP and UDP. These checks run before the SOCKS listener begins accepting +traffic. The transport default does not enable Mux; omitted `mux` remains `0`. + | Query | Values | Default | |---|---|---| -| `up`, `down` | `tcp`, `udp`, or `mix` | `udp` | -| `alpn` | exact TLS/QUIC ALPN, 1–255 bytes | `now/1` | +| `up`, `down` | `tcp`, `udp`, or `mix` | only carrier, otherwise `tcp` | | `mux` | `0` dedicated TLS lanes, `1` TLS Mux | `0` | | `sni` | verified DNS name, or `none` | `none` | | `pin` | certificate SHA-256 pin, or `none` | `none` | | `rate`, `etar` | Mbps, `0` disables limit | `0` | +| `morph` | `0` bare TLS/QUIC wire, `1` keyed wire transform | `0` | | `socks` | required local listen address, optionally credentials | — | | `log` | logging threshold | `info` | +## Native next endpoint + +The `next` value omits a scheme but otherwise uses the Vector endpoint grammar: + +```text +portal://relay-key@*/tcp4:2006?next=origin-key@origin.example/udp6:2017 +portal://relay-key@:2000?next=origin-key@origin.example/tcp:2006/udp:2017&up=tcp&down=udp +``` + +The local Portal listener and upstream endpoint are independent. The first +example accepts inbound TLS/TCP over IPv4 and opens the next hop with QUIC/UDP +over IPv6. A carrier or family chosen locally does not constrain the next hop. + +`next` must contain exactly one encoded shared key, `@`, and one endpoint. Its +host must be concrete; `*` is invalid. It has no inner query or fragment. +`up`, `down`, `mux`, `sni`, `pin`, and `morph` remain query parameters of the outer +Portal URL. Reserved bytes in the nested key are percent-encoded once and are +decoded once when the upstream credentials are built. + +The `dial` IP from the outer Portal URL also constrains native upstream +connections. The selected endpoint family and the local `dial` family must +both match a resolved upstream address. No connection crosses an explicit +family boundary to recover from a failure. + ## Option scope ```text Portal URL | - +-- listener: net, tls, crt, key, alpn + +-- listener: endpoint path, tls, crt, key, morph +-- relay: rate, etar, dial, log | +-- outbound path | +-- direct target access +-- socks --> SOCKS5 proxy --> target - +-- next --> {up, down, mux, sni, pin, alpn} --> Portal + +-- next --> {up, down, mux, sni, pin, morph} --> Portal Vector URL | - +-- Portal client: up, down, alpn, mux, sni, pin + +-- Portal client: up, down, mux, sni, pin, morph +-- SOCKS5 edge: socks +-- relay: rate, etar, log ``` @@ -94,21 +225,36 @@ The primary route must acquire all lanes within `NOW_MIX_FALLBACK_TIMEOUT` (default `1s`). Failure or timeout discards its local resources and starts the other allowed route once with a new flow ID. READY failures, target dial failures, and established payload failures do not trigger fallback. The policy -has no health score or circuit breaker. `net=mix` is the recommended upstream; -a single-family listener may consume the budget on each affected flow or leave -no legal route for a fixed direction. +has no health score or circuit breaker. Both carriers must be declared for +`mix`; a single-carrier endpoint rejects a policy that selects the absent +carrier. -With `mux=1`, Shards open lazily according to active flow pressure. New flows -use the least-loaded shard; a shard carries 4 active flows before another -opens and closes after 30 seconds fully idle. With `mux=0`, every TLS-carried +With `mux=1`, one session shares a pool of at most eight full-duplex TLS carriers. +New flows reuse idle carriers; when all are busy and a slot is available, +they establish another carrier. Establishments may run in parallel and count +against the same eight slots. At capacity, flows choose the lowest credit/queue +occupancy, breaking ties by live streams plus pending reservations. Connecting +carriers also accept reservations to balance cold bursts. Existing streams do not migrate, and a full pool +continues accepting new streams until a carrier's 4,096-stream resource ceiling. +There is no stream-density target. +A carrier closes after 30 seconds +fully idle. With `mux=0`, every TLS-carried Flow owns one on-demand lane that closes with the Flow. Mux applies when at least one direction is `tcp` or `mix`. `udp/udp&mux=1` canonicalizes to `mux=0`. -Portal and Vector advertise only their configured ALPN and require an exact -match. ALPN and Mux are independent settings. Portal's `mux` option controls -only its `next` client. Inbound Portal connections accept a `0xff`-marked Mux -carrier or an unmarked dedicated lane on the same listener. +Portal and Vector use only the fixed ALPN `nw2`. A peer that does not offer +`nw2` cannot establish a carrier. The `alpn` query is ignored under the +normal unknown-parameter rule. Portal's `mux` option controls only +its `next` client. Inbound Portal connections accept a `0xff`-marked Mux carrier +or an unmarked dedicated lane on the same listener. + +Morph is hop-local and has no negotiation or fallback. Both endpoints must +configure the same value. Compact `HOST:PORT` endpoints apply it to TCP and +UDP on the shared port; explicit paths apply it only to the carrier entries +present in the path. Values other than `0` and `1`, including an empty value, +are configuration errors. Duplicate `morph` keys follow the general rule that +the first recognized value wins. For `tls=2`, `crt` and `key` are native filesystem paths. Quote the complete URL when a Windows path, space, `&`, or another shell-significant character is @@ -116,28 +262,51 @@ present. ## URL parsing rules -- The shared key occupies the URL username. Password userinfo, URL paths, and - fragments are invalid. +- The shared key occupies the URL username. Password userinfo and fragments are + invalid. +- Endpoints use either `HOST:PORT` or + `HOST/CARRIER:PORT[/CARRIER:PORT]`; the forms cannot be combined. Empty path + segments, trailing slashes, unknown or duplicate carriers, and zero ports are + invalid. +- Portal allows `*` as the wildcard listen host. The compact + `portal://key@:port` form is equivalent to `*`; explicit + carrier paths require a host. Vector and `next` reject `*`. +- IP literals must agree with an explicit `4` or `6` carrier suffix. Hostnames + are filtered to the selected address family. - Reserved bytes in shared keys, nested credentials, and query values use percent encoding. - Recognized query keys use their first occurrence. Later duplicates and unknown keys are ignored. -- A Portal with an empty listen host binds wildcard addresses. Vector requires - a Portal host and a `socks` listener. +- The `net` query is an unknown parameter and has no effect. `/tcp:PORT` and + `/udp:PORT` select a single carrier; compact endpoints enable both carriers. - `socks=user:pass@host:port` enables RFC 1929 authentication. Omitting the credentials enables SOCKS5 no-auth. +The following inputs fail validation before a Portal or Vector reaches its +running state: + +| Invalid shape | Reason | +|---|---| +| `host:2000/tcp:2006` | Compact authority port and carrier path are mutually exclusive | +| `host/tcp:2006/` | Trailing slash creates an empty carrier segment | +| `host/tcp:2006/tcp6:2006` | TCP is declared more than once | +| `host/sctp:2000` | Carrier name is unknown | +| `192.0.2.1/tcp6:2006` | IPv4 literal conflicts with IPv6-only TCP | +| `host/tcp:0` | Carrier ports are limited to `1..=65535` | +| `*/tcp:2006` on Vector or `next` | Wildcard is limited to Portal listeners | + +Errors identify the role and invalid endpoint component, exit with a nonzero +status, and do not print shared keys. Dot segments, including percent-encoded +forms, are rejected before a URL parser can normalize the path. + ## Environment Durations use humantime syntax such as `250ms`, `15s`, `2m`, or `1h`. | Variable | Default | Purpose | |---|---:|---| -| `NOW_MAX_TCP_FLOWS` | `1024` | TCP flows per authenticated client session | -| `NOW_MAX_UDP_FLOWS` | `256` | UDP flows per authenticated client session | +| `NOW_TRANSPORT_MEMORY_PROFILE` | `throughput` | QUIC and TLS Mux profile: `memory`, `balanced`, or `throughput` | | `NOW_QUIC_UDP_QUEUE_BYTES` | `4 MiB` | QUIC datagram and reassembly byte budget | -| `NOW_QUIC_MEMORY_PROFILE` | `throughput` | QUIC profile: `memory`, `balanced`, or `throughput` | -| `NOW_MAX_PENDING_PAIRS` | `1024` | Pending split-flow pairs per Portal session | | `NOW_FLOW_PAIR_TIMEOUT` | `15s` | Portal split-flow pairing deadline | | `NOW_FLOW_SETUP_TIMEOUT` | `20s` | Client wait for `SetupResult` | | `NOW_MIX_FALLBACK_TIMEOUT` | `1s` | Primary Mix route preparation budget before fallback | @@ -154,18 +323,27 @@ Durations use humantime syntax such as `250ms`, `15s`, `2m`, or `1h`. | `NOW_SHUTDOWN_TIMEOUT` | `5s` | Graceful shutdown deadline | | `NOW_RELOAD_INTERVAL` | `1h` | Supplied-certificate reload interval | -Mux limits are library defaults with strict validation: 512 KiB per stream and -connection, 256 active streams per Mux, and 512 queued frame slots. Payload in -the queue is also charged against the 512 KiB connection window, so slot capacity -does not multiply the byte bound. The application uses a 4-flow shard density -and retires fully idle shards after 30 seconds. `NOW_MAX_TCP_FLOWS` is the hard -per-session logical TCP limit shared by TLS and QUIC. `NOW_MAX_UDP_FLOWS` is the -corresponding UDP limit shared by UoT and QUIC DATAGRAM. Excess flows fail -without waiting for capacity. QUIC internally admits the sum of both limits as -bidirectional streams; this derived capacity has no separate setting. - -Portal and Vector use the same QUIC profile regardless of ALPN or the client -Mux setting. +TLS Mux shares the transport profile's 4/8, 8/16, or 16/32 MiB stream/connection +receive windows with QUIC. A Mux carrier admits at most 4,096 active streams and +has 512 queued frame slots; queued payload remains charged against the connection +window. Each flow has at most one DATA frame queued or being written, so a bulk +writer cannot fill the shared queue. Receive queues are bounded by byte credit +without blocking unrelated flows on per-flow frame counts. The application +shares at most eight carriers across both directions and retires +fully idle shards after 30 seconds. The former TCP, UDP, SOCKS association, and +pending split-pair application quotas are absent. Independent resource admission +allows up to 1,024 accepted SOCKS clients and 1,024 active SOCKS UDP targets per +Vector. Portal pairing admits up to 4,096 active or pending claims per +authenticated session and 65,536 total. QUIC stream credit +grows with live and pending QUIC +flows, reserving setup headroom of at least 64 streams or 25% of that count and +stopping at the per-session claim budget. +This avoids the former application flow quotas and excessive eager stream allocation. +Byte budgets and setup, pairing, and idle deadlines apply; per-flow +Mux metadata and Vector SOCKS target tasks have the resource ceilings described +above. These do not impose an aggregate limit on Portal sessions or target sockets. + +Portal and Vector use the same QUIC profile regardless of the client Mux setting. The stream/connection/send windows are respectively 4/8/8 MiB for `memory`, 8/16/16 MiB for `balanced`, and 16/32/32 MiB for `throughput`. These are flow-control ceilings, not eager allocations. Larger windows are useful only diff --git a/docs/integrations.md b/docs/integrations.md index 2346f0c..e2419fd 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -26,11 +26,49 @@ An integration chooses one boundary. Applications normally use SOCKS5; alternate clients implement the wire protocol; Portal chains use the native client engine. +## Command URL endpoints + +Launchers and configuration generators produce one of two standard URL shapes: + +```text +SCHEME://KEY@HOST:PORT +SCHEME://KEY@HOST/CARRIER:PORT[/CARRIER:PORT] +``` + +The compact form declares TCP and UDP on one unrestricted port. The explicit +form declares only the listed carrier entries. `tcp`, `tcp4`, and `tcp6` map to +TLS/TCP; `udp`, `udp4`, and `udp6` map to QUIC/UDP. The numeric suffix limits +DNS and literal addresses to IPv4 or IPv6. + +Configuration integrations should preserve these invariants: + +- encode the shared key as URL username data and never as password userinfo; +- keep one shared host for both carriers; +- use either an authority port or carrier path, never both; +- emit each transport at most once and order canonical output as TCP then UDP; +- emit the compact form when unrestricted TCP and UDP share a port; +- reserve `*` and compact empty hosts for Portal listeners; +- keep `next` policy in the outer Portal query rather than adding an inner + query to the nested endpoint. +- emit `morph=1` only when both peers on that hop implement the Morph wire + transform; omission and `morph=0` are equivalent. + +Portal accepts `portal://key@:2000` as the compact wildcard alias. Explicit +Portal listeners use `portal://key@*/tcp:2006/udp:2017`. Vector and native +`next` endpoints require an IP literal or hostname. Implementations that show +or log effective configuration omit credentials and retain the normalized +carrier path. + +An invalid URL is a startup error. Integrations should display the process +error without retrying a different carrier or rewriting an explicit address +family, because doing so would change the user's declared service edge. + ## Alternate clients Implementers should follow [Protocol](protocol.md). QUIC uses native reliable -streams and DATAGRAM frames, never TLS Mux framing. Peers advertise the exact -configured ALPN. A Mux TLS connection places the `0xff` marker after +streams and DATAGRAM frames, never TLS Mux framing. Clients must offer `nw2`, +and the negotiated ALPN must be exactly `nw2`. A Mux TLS connection places +the `0xff` marker after authentication; a dedicated lane places its FlowHeader there instead. Portal accepts both forms on the same TLS listener and selects the decoder from that first byte. @@ -42,6 +80,13 @@ An alternate client provides: - nonzero Flow IDs unique among active flows in that session; - matching OPEN and ATTACH metadata for split-carrier flows; - bounded retry and reconnection behavior after carrier failure. +- the exact Morph HKDF and ChaCha20 socket wrapper when `morph=1` is selected. + +The command URL is not transmitted. It selects the remote socket used for each +physical carrier; the negotiated ALPN, AuthFrame transport byte, and FlowHeader +then identify wire behavior. TCP and UDP may arrive at different Portal ports +and still belong to one session because the authenticated `session_id`, rather +than the socket address, defines the pairing scope. The `mix` URL policy is client-side only and resolves once to TT, TQ, QT, or QQ. The primary pair has a one-second preparation budget by default. Failure or @@ -50,8 +95,18 @@ replays a request after any FlowHeader or Target bytes may have been accepted. ## Chained Portal -`next=shared-key@host:port` creates the same transport-only client engine used +`next=shared-key@host:port` or an explicit endpoint such as +`next=shared-key@host/tcp:2006/udp:2017` creates the same client engine used by Vector, including `up/down=mix` and pre-commit fallback. `mux=0|1` selects dedicated or Mux TLS when TCP can be selected and defaults to `0`; it has no effect without `next` and canonicalizes to `0` for `udp/udp`. Authentication, flow setup, bounds, and failure semantics are identical at every hop. + +The outer `morph=0|1` applies to both the inbound listener and the native next +client. The two sides derive from their respective endpoint keys, so a relay +does not reuse its inbound Morph keys on the next hop. + +The nested value contains no scheme, query, or fragment. Percent-encoded key +bytes are decoded exactly once. `up`, `down`, `mux`, `sni`, `pin`, and `morph` stay on +the outer Portal URL, while the outer `dial` address also constrains the local +family used for upstream TCP and UDP sockets. diff --git a/docs/interoperability.md b/docs/interoperability.md new file mode 100644 index 0000000..ab8a24a --- /dev/null +++ b/docs/interoperability.md @@ -0,0 +1,79 @@ +# Interoperability + +## Peer contract + +Every TLS/TCP and QUIC/UDP carrier uses TLS 1.3 and the ALPN `nw2`. The client +offers `nw2`, and the server accepts a carrier only when TLS selects it. The +authentication derivation, flow headers, setup results, and Mux frames follow +the [Nowhere wire protocol](protocol.md). + +Portal and Vector use the same wire contract. Alternate clients and native +Portal chains follow it as peers rather than relying on a separately versioned +SDK. + +V2 uses 7-byte Mux headers and 4-byte QUIC UDP base headers (12 bytes for +fragments), with a shared 30-bit flow ID range. The `nw2` protocol fixes these +frame layouts; peers and every native Portal hop use the same contract. + +## Endpoint contract + +Portal listeners, Vector remotes, and Portal `next` endpoints share two forms: + +```text +HOST:PORT +HOST/CARRIER:PORT[/CARRIER:PORT] +``` + +The compact form declares TLS/TCP and QUIC/UDP on one numeric port. The explicit +form declares only the listed carriers and may select separate ports or address +families. An empty Portal host, as in `portal://key@:2000`, selects the wildcard +listener. Vector and `next` endpoints require a dialable host. + +Endpoint syntax selects local sockets and is not transmitted on the wire. The +complete grammar and validation rules are in [Configuration](configuration.md). + +## Morph contract + +Peers use `morph=1` on both ends of a hop or `morph=0` on both ends. Morph has +no in-band marker or negotiation. TCP has one client-generated 12-byte nonce +and direction-specific keys; UDP has one 12-byte nonce per datagram and one +shared UDP key. The exact HKDF labels, counter origin, byte limits, and wire +layout are normative in [Protocol](protocol.md). + +Implementations must preserve TCP stream offsets across partial I/O and treat +each GSO/GRO segment as a separate UDP datagram. QUIC sees the decoded packet +length; the physical UDP path sees 12 additional bytes. Interoperability tests +should use fixed derivation and ChaCha20 vectors before attempting a live TLS +or QUIC handshake. + +## TLS lane contract + +| Mux setting | TLS behavior | Failure scope | +|---|---|---| +| `mux=0` | One authenticated connection per logical flow | One flow | +| `mux=1` | Logical streams share TLS carriers | Every stream on the failed carrier | + +After the 32-byte AuthFrame, `0xff` selects Mux framing; every other valid first +byte starts a dedicated FlowHeader. Portal accepts both lane forms on the same +TLS listener. + +The Mux pool is full duplex and shared by both logical directions. It opens +carriers lazily, reuses idle carriers, selects the least occupied carrier at +capacity, and contains at most eight connecting or established carriers per +session. A fully idle carrier closes after 30 seconds. There is no legacy +application stream quota; each carrier has a 4,096-stream resource ceiling. + +## Route contract + +Uplink and downlink independently use TLS/TCP or QUIC/UDP. Every concrete route +uses the same FlowHeader, Target, pairing, setup-result, and relay semantics. +`mix` is a local client policy that resolves to a concrete route before the +FlowHeader is sent. + +| `up` / `down` | `tcp` | `udp` | `mix` | +|---|---|---|---| +| `tcp` | TT | TQ | TT or TQ | +| `udp` | QT | QQ | QT or QQ | +| `mix` | TT or QT | TQ or QQ | TT or QQ | + +T denotes TLS/TCP and Q denotes QUIC/UDP, with uplink first. diff --git a/docs/operations.md b/docs/operations.md index 6ddd78a..86f127c 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -12,52 +12,100 @@ Run `nowhere` without a URL and select: - `1` Overview; - `2` Logs. +## Listener lifecycle + +Portal validates the complete URL, resolves every declared carrier, and opens +its UDP and TCP listener sets before entering `READY`. Each successful bind is +logged with its actual transport and socket address: + +```text +listening on TLS/TCP 0.0.0.0:2006 +listening on TLS/TCP [::]:2006 +listening on QUIC/UDP 0.0.0.0:2017 +listening on QUIC/UDP [::]:2017 +``` + +The effective configuration keeps the normalized logical endpoint, such as +`*/tcp:2006/udp:2017`. TUI instance summaries show the actual TCP and UDP +address lists after binding. Disabled carriers appear as `none`; shared keys +are absent from both views. + +One hostname may resolve to several addresses. Portal deduplicates the startup +result and binds every address that matches the carrier family. These sockets +form one logical carrier listener set. DNS is not refreshed while the process +runs, and an unexpected exit from any active listener set stops the service. + +Startup is all-or-nothing for declared carriers. A carrier must bind at least +one address. A port conflict, permission error, unavailable concrete address, +or explicit family failure stops startup and releases sockets already opened. +The only partial-family case is `*` with unrestricted `tcp` or `udp`: an +operating system without one address family logs a warning and continues with +the other family. + +| Symptom | Check | +|---|---| +| TCP works but QUIC does not | UDP port publication, firewall, and the endpoint's UDP entry | +| QUIC works but TCP does not | TCP port publication, firewall, and the endpoint's TCP entry | +| IPv4 works but IPv6 does not | Carrier suffix, IPv6 route, and the separate `[::]` bind log | +| Startup reports no matching address | DNS results and the carrier's `4` or `6` suffix | +| Startup reports address in use | Each transport/port pair and any duplicate service instance | +| Vector rejects `up`, `down`, or `mix` | The remote endpoint must declare every selected carrier | +| Morph peers cannot handshake | Both ends need the same `morph` value and shared key | +| QUIC fails only with Morph | The UDP path must carry at least 1212-byte payloads and allow MTU probes | + ## Capacity -The important memory bounds are the 1,024 concurrent TCP flows and 256 UDP flows -per authenticated client session, the 512 KiB per-stream and per-Mux receive -windows, 256 streams per Mux, bounded reusable relay-buffer caches, and QUIC UDP -queue/reassembly limits. UoT and QUIC DATAGRAM share the UDP flow limit. TLS -shards originated with `mux=1` by Vector or a Portal `next` client target 4 -active flows, use least-loaded placement, and close after 30 seconds fully -idle. Frame queue slots do not bypass byte credit. Windows are granted as +Payload memory is controlled by the selected 4/8, 8/16, or 16/32 MiB +per-stream/per-Mux receive windows, bounded reusable relay-buffer caches, and +QUIC UDP queue/reassembly limits. The former logical TCP, UDP, SOCKS, and +pending-pair application quotas are absent. Implementation safeguards admit at +most 4,096 active streams per Mux carrier, 1,024 accepted SOCKS clients per +Vector, and 1,024 active SOCKS UDP targets per Vector. Portal pairing admits at +most 4,096 active or pending claims per authenticated session and 65,536 total. +TLS +shards originated with `mux=1` by Vector or a Portal `next` client adapt their +pool to concurrent flow demand, stop at eight carriers per session +across both directions, use lowest-occupancy placement, and +close after 30 seconds fully idle. Frame queue slots do not bypass byte credit. Windows are granted as permits and payload is admitted incrementally. -At a session flow limit, TCP setup returns a failure immediately. A SOCKS5 UDP -packet whose logical route cannot be admitted receives no UDP response; the -association remains available for existing routes. +QUIC stream credit grows with live and pending QUIC flows plus setup headroom, +then stops at the 4,096-claim session budget. Pairing and setup deadlines reclaim +incomplete requests. QUIC uses the shared `throughput` memory profile by default. Select `balanced` or `memory` when connection density matters more than a single flow's bandwidth-delay product. +Morph adds 12 bytes once per TCP connection and 12 bytes to every UDP +datagram. It preserves TCP payload length and keeps GSO/GRO batching when the +platform provides it. UDP socket buffers reserve space for the outer nonce; +Quinn measures decoded QUIC datagram sizes and performs path MTU discovery with +12 bytes reserved for the outer nonce. Morph reuses initialized transport +buffers and applies ChaCha20 while copying between caller and wire buffers. UDP +nonce batches come from a user-space CSPRNG seeded from the operating system and +reseeded before stream exhaustion. + ### TLS Shard placement -An originating client keeps separate uplink and downlink Shard sets. Only a -direction that selects TLS/TCP uses a set; a symmetric `tcp/tcp` flow uses one -duplex stream from the uplink set. +An originating client shares one full-duplex TLS carrier pool across directions. +There is no legacy logical-flow quota; each Mux carrier has an independent +4,096-stream resource ceiling. ```text - +-------------------------+ -new TLS-carried Flow --->| live Shard below 4? | - +------------+------------+ - | - +-------------+---------------+ - | yes | no - v v - +------------------+ +------------------+ - | choose the | | open one TLS | - | least-loaded one | | Mux Shard | - +---------+--------+ +---------+--------+ - | | - +--------------+--------------+ - | - v - open logical stream +new flow --> idle carrier? --> reuse + | + +--> free slot? --> establish TLS (up to eight in parallel) + | + +--> lowest credit/queue occupancy --> multiplex + (connecting slots accept reservations too) ``` -While load grows from zero, a direction uses `ceil(active flows / 4)` Shards. -After load falls, an empty Shard remains available during its idle period: +Connections are created on demand. At most eight pool slots cover establishment +and carrier lifetime. Each slot shares one initializer and counts pending flows +alongside live streams. A cancelled initializer can be retried in the same slot. +No polling task or setup-latency threshold is used. +After load falls, an empty carrier remains available during its idle period: ```text +--------+ last stream closes +------+ 30s with no stream +--------+ @@ -110,14 +158,20 @@ configured shutdown deadline. Functional validation belongs on every deployment platform: -- Portal reaches `READY` on every configured listener; +- Portal reports every expected TCP and UDP address before reaching `READY`; +- compact endpoints accept both carriers on one port, while explicit endpoints + expose only their declared carrier/port/family combinations; +- wildcard IPv6 listeners are `V6ONLY` and coexist with IPv4 listeners on the + same numeric port; +- hostname listeners bind every deduplicated startup address, and a failed + startup releases listeners opened earlier; - Vector accepts SOCKS5 CONNECT and UDP ASSOCIATE; - every configured uplink/downlink carrier combination reaches a target; - every Mix policy resolves only to its documented concrete pairs and cleans up a failed pre-commit attempt; -- custom ALPN, credentials, certificate verification, and native chains match - at both ends; -- flow limits fail promptly instead of waiting for capacity; +- negotiated protocol version, credentials, certificate verification, and + native chains match at both ends; +- resource admission fails promptly instead of waiting for capacity; - idle Mux Shards and UDP flows retire at their documented deadlines; - graceful shutdown reaches `STOPPED` within the configured deadline; - the local TUI discovers the process without exposing credentials or payload. diff --git a/docs/platforms.md b/docs/platforms.md index 3f9bd0d..d7543a1 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -22,6 +22,32 @@ cargo build --release --locked cargo test --all-targets --locked ``` +## Network exposure + +Portal binds one socket for every address selected by each declared carrier. +The endpoint path therefore defines both process listeners and the firewall or +container rules required around the process. + +| Endpoint | Required inbound exposure | +|---|---| +| `*:2000` | TCP 2000 and UDP 2000 | +| `*/tcp:2006` | TCP 2006 | +| `*/udp:2017` | UDP 2017 | +| `*/tcp:2006/udp:2017` | TCP 2006 and UDP 2017 | +| `*/tcp4:2006/udp6:2017` | IPv4 TCP 2006 and IPv6 UDP 2017 | + +An unrestricted `*` carrier opens separate IPv4 and IPv6 wildcard sockets. +IPv6 sockets use `V6ONLY` on Linux, macOS, and Windows, so an IPv6 firewall +rule does not replace the corresponding IPv4 rule. If the operating system +does not support one family, only an unrestricted wildcard carrier may start +with the available family and a warning. Explicit families and concrete bind +addresses fail startup when unavailable. + +A hostname listener resolves once at startup and binds every matching address. +DNS changes take effect after a process restart. Vector and Portal `next` +resolve each remote carrier independently, filter by its `4` or `6` suffix, +and fail rather than crossing the declared family boundary. + ## Container image GHCR publishes `ghcr.io/nodepassproject/nowhere` for exactly two platforms: @@ -36,22 +62,37 @@ Start a Portal with its generated certificate: ```text docker run -d --rm --name nowhere-portal \ - -p 2077:2077/tcp \ - -p 2077:2077/udp \ + -p 2000:2000/tcp \ + -p 2000:2000/udp \ + ghcr.io/nodepassproject/nowhere:latest \ + 'portal://change-me@:2000' +``` + +Publish separate carrier ports when the endpoint uses an explicit path: + +```text +docker run -d --rm --name nowhere-portal \ + -p 2006:2006/tcp \ + -p 2017:2017/udp \ ghcr.io/nodepassproject/nowhere:latest \ - 'portal://change-me@:2077' + 'portal://change-me@*/tcp:2006/udp:2017' ``` +Docker publication is transport-specific. Publishing `2006/udp` does not +expose the TCP carrier, and publishing `2017/tcp` does not expose QUIC. For an +IPv4-only or IPv6-only carrier, align the Docker host binding and host firewall +with the endpoint suffix. + For `tls=2`, mount the CA-issued PEM certificate chain and private key: ```text docker run -d --rm --name nowhere-portal \ - -p 2077:2077/tcp \ - -p 2077:2077/udp \ + -p 2000:2000/tcp \ + -p 2000:2000/udp \ -v /path/fullchain.pem:/cert.pem:ro \ -v /path/private-key.pem:/key.pem:ro \ ghcr.io/nodepassproject/nowhere:latest \ - 'portal://change-me@:2077?tls=2&crt=/cert.pem&key=/key.pem' + 'portal://change-me@:2000?tls=2&crt=/cert.pem&key=/key.pem' ``` `crt` is the full certificate chain and `key` is its private key. A Vector @@ -76,13 +117,13 @@ Bourne-compatible shells and PowerShell accept the documented single-quoted URLs: ```text -nowhere 'vector://secret@portal.example:2077?up=tcp&down=udp&socks=127.0.0.1:1080' +nowhere 'vector://secret@portal.example:2000?up=tcp&down=udp&socks=127.0.0.1:1080' ``` In Windows Command Prompt, use double quotes and the `.exe` name: ```text -nowhere.exe "vector://secret@portal.example:2077?up=tcp&down=udp&socks=127.0.0.1:1080" +nowhere.exe "vector://secret@portal.example:2000?up=tcp&down=udp&socks=127.0.0.1:1080" ``` Certificate and key values accept native filesystem paths. Relative paths are diff --git a/docs/protocol.md b/docs/protocol.md index 9eee9a3..8de8b75 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -21,9 +21,95 @@ otherwise. ## 1. Carrier model -TLS/TCP and QUIC negotiate one exact ALPN. The default is `now/1`; a configured -ALPN is 1–255 bytes and has no version or Mux semantics. Both transports use -TLS 1.3. +TLS/TCP and QUIC use TLS 1.3 with the sole ALPN `nw2`. A client offers `nw2`, +and the server requires the handshake to select exactly `nw2` before Nowhere +authentication. + +### Command endpoint mapping + +The command URL chooses the socket for each physical carrier before this wire +protocol begins. Its endpoint forms map as follows: + +| Command endpoint entry | Physical carrier | Wire transport byte | +|---|---|---:| +| compact `HOST:PORT` TCP side | TLS 1.3 over TCP on `PORT` | `0x01` | +| compact `HOST:PORT` UDP side | QUIC over UDP on `PORT` | `0x02` | +| `HOST/tcp:PORT`, `HOST/tcp4:PORT`, or `HOST/tcp6:PORT` | TLS 1.3 over TCP on its own port/family | `0x01` | +| `HOST/udp:PORT`, `HOST/udp4:PORT`, or `HOST/udp6:PORT` | QUIC over UDP on its own port/family | `0x02` | + +TCP and UDP may use different ports and address families while sharing one +command endpoint host. Port numbers, hostnames, wildcard selection, and +address-family suffixes are not serialized in AuthFrame or FlowHeader. They +only select the local listener or remote socket on which a carrier is +established. + +Disabling a carrier by omitting it from an explicit endpoint does not create a +new wire mode. It prevents the local process from listening or dialing that +physical transport. Client `up`, `down`, and `mix` policy must select from the +declared carriers before a FlowHeader is encoded. + +Separate TCP and UDP socket addresses do not separate sessions. The same +authenticated `session_id` joins all physical carriers created by one client, +so split OPEN and ATTACH lanes can pair across carrier ports and IP families. +Address family is never negotiated on the wire; reachability and family +filtering complete before TLS or QUIC authentication. + +### Morph socket layer + +When the command endpoint has `morph=1`, a keyed transform sits below TLS/TCP +or QUIC/UDP. It changes the socket wire image and is removed before bytes reach +rustls or Quinn. There is no magic, version, negotiation, fallback, padding, +framing protocol, TLS parser, or QUIC parser. + +The decoded shared-key bytes are the HKDF input: + +```text +morph_root = HKDF-Extract-SHA256( + salt = ASCII("nowhere/morph"), + IKM = shared_key +) + +tcp_c2s_key = HKDF-Expand-SHA256(morph_root, ASCII("tcp c2s"), 32) +tcp_s2c_key = HKDF-Expand-SHA256(morph_root, ASCII("tcp s2c"), 32) +udp_key = HKDF-Expand-SHA256(morph_root, ASCII("udp"), 32) +``` + +Labels contain exactly the shown ASCII bytes and no trailing NUL. The cipher +is IETF ChaCha20 with a 256-bit key, 96-bit nonce, and internal block counter +starting at zero. + +For TCP, the active connector generates one nonce and sends it before TLS: + +```text +client -> server: nonce[12] || ChaCha20-XOR(TLS bytes, tcp_c2s_key, nonce) +server -> client: ChaCha20-XOR(TLS bytes, tcp_s2c_key, nonce) +``` + +The server sends no Morph prefix. Each direction has an independent stream +offset. TLS bytes retain their length and the connection adds exactly 12 bytes. +A direction stops before counter exhaustion, after at most `2^38 - 64` +transformed bytes, and never wraps or rekeys. + +For UDP, every socket datagram is independent in either direction: + +```text +wire datagram = nonce[12] || ChaCha20-XOR(QUIC datagram, udp_key, nonce) +``` + +TCP nonces come directly from the operating system CSPRNG. Each UDP socket +seeds a user-space ChaCha20 CSPRNG from the operating system and reseeds it +before its stream is exhausted. Receivers drop wire datagrams of 12 bytes or +fewer. Morph adds 12 bytes to every QUIC datagram, including Retry, stateless +reset, handshake, application, and MTU-probe packets. QUIC's 1200-byte minimum +therefore requires a path capable of carrying a 1212-byte UDP payload. With +Morph enabled, Quinn's default 1452-byte path-MTU discovery upper bound is +reduced to 1440 bytes, keeping the transformed UDP payload at 1452 bytes. + +The command URL controls Morph for every carrier declared by that endpoint. +For Portal chaining, the outer `morph` value controls both adjacent hops while +each hop derives keys from its own shared key. Morph adds no authentication, +integrity, replay defense, traffic-analysis resistance, or session security; +the TLS/QUIC and AuthFrame layers retain those responsibilities. One client session has one random 16-byte `session_id`. Every physical carrier is authenticated with that ID, so Portal can pair logical lanes belonging to @@ -75,7 +161,7 @@ Client -> Portal +------------+------------+-------------+-------------+-----+ | AuthFrame | Mux marker | MuxFrame | MuxFrame | ... | -| 32 bytes | 0xff | 8 + N bytes | 8 + N bytes | | +| 32 bytes | 0xff | 7 + N bytes | 7 + N bytes | | +------------+------------+-------------+-------------+-----+ Reconstructed logical stream @@ -142,7 +228,7 @@ The shared key is 1–255 decoded bytes and is never transmitted. Authentication uses these fixed derivations: ```text -salt = SHA256("nowhere/now/1/auth-root") +salt = SHA256("nowhere/nw2/auth-root") auth_root = HMAC-SHA256(salt, shared_key) auth_key = HMAC-SHA256(auth_root, "authentication" || 0x01) @@ -154,8 +240,7 @@ tag = first 16 bytes of transport || exporter[32] || session_id[16]) ``` -The 32-byte exporter uses label `EXPORTER-Nowhere-Auth` and empty context. The -fixed derivation labels do not change when a custom ALPN is configured. +The 32-byte exporter uses label `EXPORTER-Nowhere-Auth` and empty context. Authentication is bound to the current TLS connection; replaying a captured AuthFrame on another connection fails. @@ -182,57 +267,54 @@ Mux frame. ### MuxHeader -Every Mux frame starts with an 8-byte header. STREAM and DATAGRAM frames carry -exactly `value` payload bytes; WINDOW carries no payload. +Every Mux frame starts with a 7-byte header. A DATA frame carries +exactly `value` payload bytes; control frames carry no payload. ```text -MuxHeader - 8 bytes +MuxHeader - 7 bytes - offset 0 1 2 4 8 - +--------+--------+---------------+-----------------------+ - | kind | flags | value | flow_id | - | u8 | u8 | u16 | u32 | - +--------+--------+---------------+-----------------------+ + offset 0 1 3 7 + +--------+---------------+-----------------------+ + | kind | value | flow_id | + | u8 | u16 | u32 | + +--------+---------------+-----------------------+ ``` | `kind` | Name | `value` | `flow_id` | |---:|---|---|---| -| `0x01` | STREAM | payload length | nonzero | -| `0x02` | WINDOW | returned byte credit | `0` for connection, nonzero for stream | -| `0x03` | DATAGRAM | payload length | nonzero | - -The runtime implements STREAM and WINDOW. DATAGRAM headers are recognized by -the codec but are not registered as a runtime plane; receiving one closes the -Mux carrier as unsupported. - -For STREAM, the low three flag bits are: - -```text -flags byte - - bit 7 3 2 1 0 - +---------------------+-----+-----+-----+ - | reserved | RST | FIN | SYN | - +---------------------+-----+-----+-----+ -``` - -- `SYN=0x01` creates the logical stream before optional payload is delivered. -- `FIN=0x02` half-closes the sender after optional payload is delivered. -- `RST=0x04` resets the stream. It MUST be the only flag and `value` MUST be 0. -- All other flag bits MUST be zero. - -WINDOW uses `flags=0`, carries no payload, and requires nonzero credit. A +| `0x01` | OPEN | opener receive-window extension in 1 KiB units | nonzero | +| `0x02` | DATA | payload length, 1..65535 | nonzero | +| `0x03` | WINDOW | returned credit in 1 KiB units | `0` for connection, nonzero for stream | +| `0x04` | FIN | always `0` | nonzero | +| `0x05` | RESET | always `0` | nonzero | + +OPEN carries no payload and extends +the opener's 4 MiB initial stream receive window. The runtime emits DATA +payloads of at most 32 KiB. + +FIN and RESET carry no payload. FIN half-closes a logical stream; RESET +immediately removes it. Other frame kinds are invalid. Every nonzero `flow_id` +must be at most `0x3fffffff`; the upper two bits of its u32 field must be zero. + +WINDOW carries no payload and requires nonzero credit in 1 KiB +units. A WINDOW with `flow_id=0` replenishes connection credit; a nonzero ID replenishes that logical stream. Credit that would exceed the configured window closes the carrier. A late stream-local WINDOW for an already closed stream is ignored. -STREAM data for an unknown flow is a carrier error. Late FIN or RST processing +DATA for an unknown flow is a carrier error. Late or duplicate FIN/RESET processing is idempotent. Closing the physical Mux carrier fails every logical stream on that carrier. -The runtime emits at most 32 KiB of data per STREAM frame. Default Mux bounds -are 512 KiB per-stream receive credit, 512 KiB connection-wide receive credit, -256 active streams, and 512 queued outbound frame slots. Payload must obtain +Mux uses an initial 4 MiB stream window and 8 MiB connection window. Each side +sends one WINDOW to extend its connection window. OPEN advertises the opener's +stream extension; the receiver returns its stream extension with WINDOW. +The selected transport profile sets final windows to 4/8, 8/16, or 16/32 MiB. +Each carrier admits at most 4,096 active streams as an implementation resource +ceiling, independent of application flow policy. Each carrier has 512 queued +outbound frame slots and 4,096 queued terminal-delivery slots; each stream may +have one DATA frame queued or being written. +Payload must obtain both stream and connection credit before it enters the outbound queue. ```text @@ -250,10 +332,28 @@ application write Both credit checks precede queue admission. A stream therefore cannot reserve payload beyond either advertised receive window. -Client-side Shards open lazily in separate uplink and downlink sets. A new flow -uses the least-loaded live Shard for its TLS direction; a new Shard opens when -all live Shards in that set have 4 active flows. A symmetric `tcp/tcp` flow -uses one duplex stream from the uplink set. A fully idle Shard closes after 30 +Client-side TLS Mux carriers share one session pool, with at most eight established +or connecting carriers combined. Each TLS carrier is full duplex. New flows +reuse idle carriers first. If all are busy and capacity remains, the new flow +establishes another carrier; independent establishments run concurrently. At +capacity, new flows use the carrier with the lowest maximum occupancy of send +credit, receive credit, and outbound frame slots; stream count plus pending +reservations breaks ties. Connecting slots also accept reservations, so a cold +burst does not pile onto the first completed handshake. Each slot shares one +initializer; cancellation allows a waiter to retry it. Failed expansion can +fall back to an established carrier. There is no stream-density target, latency +threshold, background polling, or migration +of established streams. This favors parallel throughput over minimizing the +number of carriers for many idle logical streams. + +Receive queues use byte-credit admission rather than blocking the carrier reader +on a per-flow frame count. Every DATA frame consumes at least one KiB of credit, +bounding queued payload and DATA metadata across the carrier. Separate OPEN +admission caps active streams, pending incoming deliveries, and pending terminal +deliveries separately at 4,096 per carrier. A full incoming or terminal queue +closes the carrier immediately without blocking its reader; OPEN/RESET churn +cannot bypass these queue limits. Authentication remains +separate from Mux placement. A fully idle carrier closes after 30 seconds. Portal applies the same timeout to an authenticated Mux carrier with no active streams. Sharding is runtime placement and does not add wire fields. @@ -289,8 +389,9 @@ Field values: | `down` | 4 | `0=TLS/TCP`, `1=QUIC` | | `hops` | 7..5 | remaining Portal forwarding budget, `0..7` | -`flow_id` is nonzero and is scoped to `session_id`. The same logical flow uses -the same ID on OPEN and ATTACH, in MuxHeader, and in QUIC UDP DATAGRAM frames. +`flow_id` is in `1..=0x3fffffff` and is scoped to `session_id`. Its u32 field's +upper two bits must be zero. The same logical flow uses the same ID on OPEN +and ATTACH, in MuxHeader, and in QUIC UDP DATAGRAM frames. Role semantics: @@ -390,7 +491,7 @@ SetupResult - 1 byte | `0x01` | INVALID_REQUEST | malformed or carrier-inconsistent setup | | `0x02` | METADATA_CONFLICT | OPEN and ATTACH metadata conflict | | `0x03` | PAIR_TIMEOUT | the matching split lane did not arrive | -| `0x04` | FLOW_LIMIT | admission, session flow, or forwarding limit reached | +| `0x04` | FLOW_LIMIT | admission or forwarding limit reached | | `0x05` | DIAL_FAILED | target or upstream connection failed | | `0x06` | SESSION_REPLACED | a newer authenticated carrier replaced this session state | | `0x07` | INTERNAL_ERROR | local processing failure | @@ -434,34 +535,28 @@ Every DATAGRAM contains exactly one DATA, FRAGMENT, or CLOSE frame. ### Common DATA/CLOSE header ```text -QUIC UDP DATA or CLOSE - 5 + N bytes +QUIC UDP DATA or CLOSE - 4 + N bytes - offset 0 1 5 - +------------------------+-----------------------+ - | flags | flow_id | - | u8 | u32 | - +------------------------+-----------------------+ + offset 0 4 + +------------------------------------------------+ + | type:2 | flow_id:30 | + | u32, network byte order | + +------------------------------------------------+ | payload ... | DATA only +------------------------------------------------+ - -flags byte - - bit 7 2 1 0 - +-----------------------------+-----------+ - | reserved, MUST be zero | type | - | 6 bits | 2 bits | - +-----------------------------+-----------+ ``` | `type` | Name | Payload | |---:|---|---| | `0b00` | DATA | remaining DATAGRAM bytes; zero length is valid | -| `0b01` | FRAGMENT | uses the 13-byte header below | -| `0b10` | CLOSE | none; total DATAGRAM length MUST be 5 | +| `0b01` | FRAGMENT | uses the 12-byte header below | +| `0b10` | CLOSE | none; total DATAGRAM length MUST be 4 | | `0b11` | invalid | — | -`flow_id` is nonzero. DATA has no payload-length field because the QUIC -DATAGRAM boundary supplies the length. CLOSE immediately removes the UDP route. +The common word is `(type << 30) | flow_id`, with type in bits 31..30 and +`flow_id` in bits 29..0. `flow_id` is in `1..=0x3fffffff`. DATA has no +payload-length field because the QUIC DATAGRAM boundary supplies the length. +CLOSE immediately removes the UDP route. ### Fragment header @@ -469,13 +564,13 @@ Packets that exceed the current QUIC maximum DATAGRAM size are divided into 2–255 fragments. ```text -QUIC UDP FRAGMENT - 13 + N bytes +QUIC UDP FRAGMENT - 12 + N bytes - offset 0 1 5 9 10 11 13 - +------+------------+------------+----------+---------+------------+ - | 0x01 | flow_id | packet_id | frag_ix | count | total_len | - | u8 | u32 | u32 | u8 | u8 | u16 | - +------+------------+------------+----------+---------+------------+ + offset 0 4 8 9 10 12 + +--------------------+------------+----------+---------+------------+ + | type:2|flow_id:30 | packet_id | frag_ix | count | total_len | + | u32 | u32 | u8 | u8 | u16 | + +--------------------+------------+----------+---------+------------+ | fragment payload, N > 0 | +------------------------------------------------------------------+ ``` @@ -506,14 +601,21 @@ ATTACH. ## 11. Runtime limits and failure scope -One authenticated client session admits 1,024 concurrent logical TCP flows and -256 concurrent logical UDP flows by default. Pending flows count toward the -same limits. A full-duplex flow counts once regardless of its carrier -combination. Admission at the limit returns FLOW_LIMIT without waiting. - -The QUIC bidirectional-stream ceiling is derived from both flow limits: 1,280 -by default. A QUIC TCP flow owns one reliable stream. A QUIC UDP flow owns one -reliable control stream plus its DATAGRAM route. +The former application-level TCP, UDP, and pending-pair quotas are absent. +Independent implementation safeguards admit at most 4,096 active streams per +Mux carrier, 1,024 accepted SOCKS clients per Vector, and 1,024 active SOCKS UDP +targets per Vector. Portal admits at most 4,096 active or pending claims per +authenticated session and 65,536 claims across its pairing registry. +Active flow IDs are unique within `1..=0x3fffffff`. Allocation wraps to 1, +skips IDs held by live leases, and fails when the space is exhausted. Released +IDs may be reused; this does not provide generation isolation for delayed +messages. Byte flow control, queue budgets, and pairing/setup timeouts apply. + +QUIC bidirectional-stream credit grows with live and pending QUIC flows, with +setup headroom of max(64, live / 4), and is clamped to the 4,096-claim session +budget. A QUIC TCP flow owns one reliable stream; +a QUIC UDP flow owns one reliable control stream plus its DATAGRAM route. +This is sliding transport credit, not a fixed application concurrency ceiling. Failure scope follows the physical carrier: diff --git a/docs/quick-start.md b/docs/quick-start.md index 368eaea..cd47d77 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -24,26 +24,77 @@ observer and does not start, stop, or reconfigure either process. ## 1. Start Portal ```text -nowhere 'portal://secret@:2077?log=info' +nowhere 'portal://secret@:2000?log=info' ``` -Portal listens for TLS/TCP and QUIC on the same numeric port when `net=mix` -(the default). +The compact endpoint listens for TLS/TCP and QUIC on the same numeric port. +Use `portal://secret@*/tcp4:2006` for a TCP-only IPv4 listener, or +`portal://secret@*/tcp:2006/udp:2017` to use separate ports. + +On a host with IPv4 and IPv6 available, choose a listener form from the service +edge you want to expose: + +| Portal endpoint | TCP listeners | UDP listeners | +|---|---|---| +| `@:2000` | `0.0.0.0:2000`, `[::]:2000` | `0.0.0.0:2000`, `[::]:2000` | +| `@*:2000` | `0.0.0.0:2000`, `[::]:2000` | `0.0.0.0:2000`, `[::]:2000` | +| `@*/tcp:2006/udp:2017` | `0.0.0.0:2006`, `[::]:2006` | `0.0.0.0:2017`, `[::]:2017` | +| `@*/tcp4:2006` | `0.0.0.0:2006` | disabled | +| `@*/udp6:2017` | disabled | `[::]:2017` | + +The IPv6 listeners are `V6ONLY`; the IPv4 and IPv6 rows represent separate +sockets. A hostname or IP literal replaces `*` when the Portal should bind +only selected interfaces. Hostnames are resolved once during startup. + +For independent carrier ports, start Portal with: + +```text +nowhere 'portal://secret@*/tcp:2006/udp:2017?log=info' +``` + +Portal prints one listening line for each bound TCP or UDP address. The TUI +shows the actual address lists after startup. A carrier must bind at least one +address; explicit families, concrete addresses, permission errors, and occupied +ports fail startup. Only an unrestricted `*` listener may continue when the +operating system does not support one address family. ## 2. Start Vector -Dedicated TLS lanes in both directions: +Dedicated TLS lanes in both directions use the compact endpoint defaults: ```text -nowhere 'vector://secret@127.0.0.1:2077?up=tcp&down=tcp&socks=127.0.0.1:1080' +nowhere 'vector://secret@127.0.0.1:2000?socks=127.0.0.1:1080' ``` QUIC in both directions: ```text -nowhere 'vector://secret@127.0.0.1:2077?up=udp&down=udp&socks=127.0.0.1:1080' +nowhere 'vector://secret@127.0.0.1:2000?up=udp&down=udp&socks=127.0.0.1:1080' ``` +When Portal uses independent ports, Vector declares the same endpoint: + +```text +nowhere 'vector://secret@127.0.0.1/tcp:2006/udp:2017?up=tcp&down=udp&socks=127.0.0.1:1080' +``` + +The carrier path describes what can be dialed. `up` and `down` choose from +those carriers for each logical direction. Compact and explicit dual-carrier +endpoints default both directions to TCP with Mux disabled. A single-carrier +endpoint needs no explicit direction policy: + +```text +nowhere 'vector://secret@127.0.0.1/tcp4:2006?socks=127.0.0.1:1080' +nowhere 'vector://secret@[::1]/udp6:2017?socks=127.0.0.1:1080' +``` + +The first command defaults both directions to TCP; the second defaults both to +UDP. Vector rejects `up`, `down`, or `mix` when the endpoint does not declare +the required carrier. Vector resolves TCP and UDP independently and never +ignores a `4` or `6` suffix. Check the Portal log, local firewall, container +port publication, and the Vector endpoint together when one carrier is +unreachable. + The full route-policy matrix is: | `up` ↓ / `down` → | `tcp` | `udp` | `mix` | @@ -58,23 +109,22 @@ route per flow and can use the other once if primary preparation fails. Stateless per-flow selection across full-duplex TLS and QUIC uses: ```text -nowhere 'vector://secret@127.0.0.1:2077?up=mix&down=mix&socks=127.0.0.1:1080' +nowhere 'vector://secret@127.0.0.1:2000?up=mix&down=mix&socks=127.0.0.1:1080' ``` `mix/mix` chooses `tcp/tcp` or `udp/udp` once per flow. A single mixed -direction can resolve to a split carrier pair. `net=mix` makes every matrix -cell reachable. The primary choice has a `NOW_MIX_FALLBACK_TIMEOUT` budget -(default `1s`). +direction can resolve to a split carrier pair. Declaring both carriers makes +every matrix cell reachable. The primary choice has a +`NOW_MIX_FALLBACK_TIMEOUT` budget (default `1s`). TLS Mux is enabled on Vector. Portal recognizes the marked carrier automatically: ```text -nowhere 'vector://secret@127.0.0.1:2077?up=tcp&down=tcp&mux=1&socks=127.0.0.1:1080' +nowhere 'vector://secret@127.0.0.1:2000?up=tcp&down=tcp&mux=1&socks=127.0.0.1:1080' ``` -ALPN defaults to `now/1`. Set the same `alpn=` on Portal and Vector when -a custom identifier is required. ALPN does not enable or disable Mux. +Both peers use the fixed `nw2` ALPN. The ALPN is not configurable. `udp/udp&mux=1` is canonicalized to `mux=0` because no TLS lane can use it. ## 3. Use SOCKS5 diff --git a/docs/security.md b/docs/security.md index f7a3978..e90174e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -22,6 +22,56 @@ TLS is version 1.3. Deployments may use a certificate pin, normal system-root verification with SNI, or the explicitly configured unverified certificate mode used by generated local certificates. +## Morph boundary + +With `morph=1`, HKDF-SHA256 derives separate TCP client-to-server, +TCP server-to-client, and UDP keys from the endpoint shared key. ChaCha20 XOR +then masks the TLS stream or each QUIC datagram below the secure transport. +The nonce is public: TCP carries one 12-byte client nonce per connection and +UDP carries one 12-byte nonce per datagram. + +An observer without the shared key cannot directly recover the bare TLS/QUIC +wire image or feed captured bytes directly to a generic TLS/QUIC parser. Morph +does not authenticate bytes, detect modification, reject replay, hide lengths +or timing, imitate HTTPS, or provide session security. TLS/QUIC and AuthFrame +remain mandatory. Random nonces can collide, UDP maintains no replay state, +and TCP does not remember previously used client nonces. Shared keys therefore +need adequate entropy; HKDF does not make a guessable key expensive to search. + +Morph has no negotiation or downgrade path. A missing setting or wrong key +appears as a TLS/QUIC handshake failure or timeout rather than a distinct +authenticated Morph error. + +## Endpoint exposure + +The service endpoint is also the network exposure policy. A compact Portal +endpoint exposes TLS/TCP and QUIC/UDP on the same port. An explicit path exposes +only its declared carriers, ports, and address families: + +```text +portal://key@*/tcp4:2006 +portal://key@192.0.2.10/tcp:2006/udp:2017 +portal://key@[2001:db8::10]/udp6:2017 +``` + +`*` and the compact empty host bind wildcard interfaces. Use a concrete local +address when the service should be limited to one interface, and enforce the +same transport, port, and family policy in host and perimeter firewalls. Every +IPv6 listener is `V6ONLY`, so IPv4 exposure is always represented by a separate +socket and firewall decision. + +A hostname listener binds all matching addresses resolved at startup. The +result is not refreshed dynamically, which prevents a later DNS answer from +silently expanding a running process, but operators must review the complete +startup address list after each restart. Vector and native `next` honor +explicit family suffixes and never retry through the other family. + +Effective URLs, startup summaries, TUI descriptors, and configuration errors +omit the shared key. The original command URL still contains the credential; +protect shell history, process arguments, service-manager configuration, and +deployment logs accordingly. Reserved key bytes must be percent-encoded, and +nested `next` credentials are decoded exactly once. + ## Admission Portal bounds pre-authentication work and applies per-source admission before @@ -36,46 +86,37 @@ A receiver charges both windows before delivery and returns credit only after application consumption. Closing the carrier releases queued payload. The fixed maximum frame payload is 65,535 bytes and the runtime emits at most -32 KiB per STREAM frame. Malformed kinds, flags, IDs, lengths, window overflow, +32 KiB per DATA frame. Malformed kinds, codes, IDs, lengths, window overflow, and DATA for unknown streams close the carrier. Late terminal and credit frames for a terminal stream are idempotent. -Default limits are 512 KiB per stream and per Mux connection and 256 active -streams per Mux. With client `mux=1`, Vector or Portal `next` places at most 4 -active flows on a shard before opening another, distributes new flows to the -least-loaded shard, and closes a fully idle shard after 30 seconds. One +The transport memory profile bounds Mux stream/connection windows at 4/8, +8/16, or 16/32 MiB. Each Mux carrier admits at most 4,096 active streams. With client `mux=1`, +Vector or Portal `next` shares at most eight TLS carriers across both directions, +reuses idle carriers before creating more, distributes flows by occupancy at capacity, +and closes a fully idle carrier after 30 seconds. Stream and pending lifecycle +metadata remain proportional to admitted streams. Active streams, pending +incoming deliveries, and terminal deliveries each have a separate 4,096-entry +ceiling, so OPEN/RESET churn cannot grow either delivery queue without bound. +Queue overflow closes the carrier without blocking its reader. One authenticated inbound Mux carrier is subject to the same fully idle timeout. -One authenticated client session admits at most 1,024 concurrent logical TCP -flows and 256 logical UDP flows across all of its carriers. UoT and QUIC -DATAGRAM flows share the UDP limit. -Local fair credit prevents one stream from monopolizing a shared window. The -finite frame queue has 512 slots, but payload admission is capped by the -512 KiB byte window; empty SYN/FIN/WINDOW frames cannot turn those slots into +The former authenticated-session logical-flow quotas are absent. Independent +resource admission caps Mux streams, accepted SOCKS clients, active SOCKS UDP +targets, and Portal flow claims. Each authenticated Portal session admits 4,096 +active or pending claims, with 65,536 across the pairing registry; byte windows +do not bound those resources. +Per-stream and connection credit plus OPEN admission limit how much +one stream can occupy. The finite frame queue has 512 slots, but +payload admission is capped by the selected connection window; empty +OPEN/FIN/RESET/WINDOW frames cannot turn those slots into retained application payload. These are credit ceilings rather than eagerly allocated payload buffers. -```text -authenticated client session - | - +-- TCP budget: 1,024 active flows - | | - | +-- dedicated TLS lane - | +-- Mux stream --> TLS Shard, target density 4 - | +-- QUIC reliable stream - | - +-- UDP budget: 256 active flows - | - +-- UoT stream --> dedicated TLS lane or Mux Shard - +-- QUIC control stream + DATAGRAM route -``` - -The TCP and UDP budgets are per authenticated session rather than process-wide. -Multiple sessions using the same shared key receive independent flow budgets. -All Shards from one session share its TCP or UDP admission budget. The shared -key is a credential, not a stable user identity, so Portal does not aggregate -these limits across every client that knows the same key. Operators control -aggregate exposure through key distribution, host resource limits, and -network-level admission policy. +TCP, UoT, and QUIC flows all follow the same policy: byte budgets, lifecycle +timeouts, and resource admission apply without restoring legacy application quotas. QUIC expands +stream credit with actual demand and clamps it to the session claim budget. +Operators control aggregate exposure through key distribution, host resource +limits, and network-level admission policy. Relay scratch buffers use bounded reuse caches: each process retains at most 64 TCP buffers and 32 UDP buffers. A short-lived concurrency spike therefore diff --git a/src/common/alpn.rs b/src/common/alpn.rs index 7e4516e..e396f7d 100644 --- a/src/common/alpn.rs +++ b/src/common/alpn.rs @@ -1,21 +1,10 @@ // Copyright (C) 2026 NodePassProject // SPDX-License-Identifier: GPL-3.0-only -//! Configurable ALPN and the TLS Mux wire marker. +//! TLS Mux wire marker. -use anyhow::{Result, bail}; - -pub(crate) const DEFAULT_ALPN: &str = "now/1"; pub(crate) const MUX_MARKER: u8 = 0xff; -pub(crate) fn parse_alpn(value: Option<&str>) -> Result { - let value = value.unwrap_or(DEFAULT_ALPN); - if value.is_empty() || value.len() > u8::MAX as usize { - bail!("alpn length must be 1..255 bytes"); - } - Ok(value.to_owned()) -} - #[cfg(test)] #[path = "../tests/common/alpn.rs"] mod tests; diff --git a/src/common/config.rs b/src/common/config.rs index 793838d..2d8664a 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -21,10 +21,6 @@ pub const DEFAULT_TELEMETRY_INTERVAL: Duration = Duration::from_secs(1); pub const MIN_TELEMETRY_INTERVAL: Duration = Duration::from_millis(250); /// Slowest supported structured telemetry cadence. pub const MAX_TELEMETRY_INTERVAL: Duration = Duration::from_secs(60); -/// Default concurrent logical TCP flows per authenticated client session. -pub const DEFAULT_MAX_TCP_FLOWS: u32 = 1024; -/// Default concurrent logical UDP flows per authenticated client session. -pub const DEFAULT_MAX_UDP_FLOWS: usize = 256; /// Parses the first value of each recognized URL query key without treating /// `+` as a space. Unknown keys and later duplicates are ignored. @@ -67,7 +63,7 @@ fn decode_query_component(raw: &str, name: &str) -> Result { || !bytes[index + 1].is_ascii_hexdigit() || !bytes[index + 2].is_ascii_hexdigit() { - bail!("common::config::query_first: invalid percent encoding in {name}"); + bail!("invalid percent encoding in {name}"); } index += 3; } else { @@ -76,7 +72,7 @@ fn decode_query_component(raw: &str, name: &str) -> Result { } percent_decode_str(raw) .decode_utf8() - .with_context(|| format!("common::config::query_first: invalid UTF-8 in {name}")) + .with_context(|| format!("invalid UTF-8 in {name}")) .map(|value| value.into_owned()) } @@ -110,16 +106,6 @@ pub fn rate_limit_bytes_per_second(mbps: i32) -> u64 { if mbps <= 0 { 0 } else { mbps as u64 * 125_000 } } -/// Maximum concurrent logical TCP flows in one authenticated client session. -pub fn max_tcp_flows() -> u32 { - env_int("NOW_MAX_TCP_FLOWS", DEFAULT_MAX_TCP_FLOWS as i32) as u32 -} - -/// Maximum concurrent logical UDP flows in one authenticated client session. -pub fn max_udp_flows() -> usize { - env_int("NOW_MAX_UDP_FLOWS", DEFAULT_MAX_UDP_FLOWS as i32).max(1) as usize -} - /// Per-direction TCP relay buffer size. pub fn tcp_data_buf_size() -> usize { env_int("NOW_TCP_DATA_BUF_SIZE", 32 * 1024) as usize diff --git a/src/common/endpoint.rs b/src/common/endpoint.rs new file mode 100644 index 0000000..43f783f --- /dev/null +++ b/src/common/endpoint.rs @@ -0,0 +1,227 @@ +// Copyright (C) 2026 NodePassProject +// SPDX-License-Identifier: GPL-3.0-only + +//! Shared Portal service endpoint URL grammar. + +use std::fmt; +use std::net::IpAddr; + +use anyhow::{Result, anyhow, bail}; +use url::Url; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AddressFamily { + Any, + V4, + V6, +} + +impl AddressFamily { + pub(crate) const fn accepts(self, ip: IpAddr) -> bool { + match self { + Self::Any => true, + Self::V4 => ip.is_ipv4(), + Self::V6 => ip.is_ipv6(), + } + } + + fn suffix(self) -> &'static str { + match self { + Self::Any => "", + Self::V4 => "4", + Self::V6 => "6", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct CarrierEndpoint { + pub(crate) port: u16, + pub(crate) family: AddressFamily, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ServiceEndpoint { + pub(crate) host: String, + pub(crate) tcp: Option, + pub(crate) udp: Option, +} + +impl ServiceEndpoint { + pub(crate) fn parse(url: &Url, allow_wildcard: bool, context: &str) -> Result { + let host = url + .host_str() + .filter(|host| !host.is_empty()) + .ok_or_else(|| anyhow!("{context}: missing host"))? + .trim_start_matches('[') + .trim_end_matches(']') + .to_owned(); + if host == "*" && !allow_wildcard { + bail!("{context}: wildcard host is only valid for Portal listeners"); + } + + let (tcp, udp) = if url.path().is_empty() { + let port = required_port(url.port(), context)?; + let endpoint = CarrierEndpoint { + port, + family: AddressFamily::Any, + }; + (Some(endpoint), Some(endpoint)) + } else { + if url.port().is_some() { + bail!( + "{context}: choose either HOST:PORT or HOST/CARRIER:PORT; authority port and carrier path cannot be combined" + ); + } + parse_carrier_path(url.path(), context)? + }; + + let endpoint = Self { host, tcp, udp }; + endpoint.validate_literal_families(context)?; + Ok(endpoint) + } + + fn validate_literal_families(&self, context: &str) -> Result<()> { + let Ok(ip) = self.host.parse::() else { + return Ok(()); + }; + for endpoint in [self.tcp, self.udp].into_iter().flatten() { + if !endpoint.family.accepts(ip) { + bail!("{context}: address family does not match host {ip}"); + } + } + Ok(()) + } + + pub(crate) const fn has_tcp(&self) -> bool { + self.tcp.is_some() + } + + pub(crate) const fn has_udp(&self) -> bool { + self.udp.is_some() + } + + pub(crate) fn carrier_addr(&self, endpoint: CarrierEndpoint) -> String { + format_host_port(&self.host, endpoint.port) + } + + pub(crate) fn canonical(&self) -> String { + if let (Some(tcp), Some(udp)) = (self.tcp, self.udp) + && tcp.family == AddressFamily::Any + && udp.family == AddressFamily::Any + && tcp.port == udp.port + { + return format_host_port(&self.host, tcp.port); + } + let mut value = format_host(&self.host); + if let Some(tcp) = self.tcp { + value.push_str(&format!("/tcp{}:{}", tcp.family.suffix(), tcp.port)); + } + if let Some(udp) = self.udp { + value.push_str(&format!("/udp{}:{}", udp.family.suffix(), udp.port)); + } + value + } +} + +/// Validates the endpoint path before `url::Url` can normalize dot segments. +/// +/// Other URL structure remains the responsibility of the standard parser. +pub fn validate_endpoint_url_input(raw: &str, context: &str) -> Result<()> { + let Some((scheme, remainder)) = raw.split_once("://") else { + return Ok(()); + }; + if !scheme.eq_ignore_ascii_case("portal") && !scheme.eq_ignore_ascii_case("vector") { + return Ok(()); + } + let endpoint = remainder + .split_once(['?', '#']) + .map_or(remainder, |(endpoint, _)| endpoint); + let Some(path_start) = endpoint.find('/') else { + return Ok(()); + }; + parse_carrier_path(&endpoint[path_start..], context).map(|_| ()) +} + +fn parse_carrier_path( + path: &str, + context: &str, +) -> Result<(Option, Option)> { + let raw = path + .strip_prefix('/') + .ok_or_else(|| anyhow!("{context}: carrier path must start with '/'"))?; + if raw.is_empty() || raw.ends_with('/') || raw.split('/').any(str::is_empty) { + bail!("{context}: carrier path must not contain empty segments or a trailing slash"); + } + let mut tcp = None; + let mut udp = None; + for segment in raw.split('/') { + if is_dot_segment(segment) { + bail!("{context}: carrier path must not contain '.' or '..' segments"); + } + let (carrier, raw_port) = segment.split_once(':').ok_or_else(|| { + anyhow!("{context}: carrier segment {segment:?} must use CARRIER:PORT") + })?; + if raw_port.is_empty() || !raw_port.bytes().all(|byte| byte.is_ascii_digit()) { + bail!("{context}: carrier port in {segment:?} must contain decimal digits only"); + } + let port = raw_port + .parse::() + .ok() + .filter(|port| *port != 0) + .ok_or_else(|| anyhow!("{context}: carrier port must be in 1..=65535"))?; + let (name, slot, family) = match carrier { + "tcp" => ("TCP", &mut tcp, AddressFamily::Any), + "tcp4" => ("TCP", &mut tcp, AddressFamily::V4), + "tcp6" => ("TCP", &mut tcp, AddressFamily::V6), + "udp" => ("UDP", &mut udp, AddressFamily::Any), + "udp4" => ("UDP", &mut udp, AddressFamily::V4), + "udp6" => ("UDP", &mut udp, AddressFamily::V6), + _ => bail!( + "{context}: unknown carrier {carrier:?}; expected tcp, tcp4, tcp6, udp, udp4, or udp6" + ), + }; + if slot.is_some() { + bail!("{context}: {name} carrier is declared more than once"); + } + *slot = Some(CarrierEndpoint { port, family }); + } + Ok((tcp, udp)) +} + +fn is_dot_segment(segment: &str) -> bool { + matches!( + segment.to_ascii_lowercase().as_str(), + "." | ".." | "%2e" | ".%2e" | "%2e." | "%2e%2e" + ) +} + +impl fmt::Display for ServiceEndpoint { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.canonical()) + } +} + +fn required_port(port: Option, context: &str) -> Result { + port.filter(|port| *port != 0) + .ok_or_else(|| anyhow!("{context}: compact endpoint requires a port in 1..=65535")) +} + +pub(crate) fn format_host_port(host: &str, port: u16) -> String { + match host.parse::() { + Ok(IpAddr::V6(ip)) => format!("[{ip}]:{port}"), + Ok(IpAddr::V4(ip)) => format!("{ip}:{port}"), + Err(_) => format!("{host}:{port}"), + } +} + +fn format_host(host: &str) -> String { + match host.parse::() { + Ok(IpAddr::V6(ip)) => format!("[{ip}]"), + _ => host.to_owned(), + } +} + +#[cfg(test)] +#[path = "../tests/common/endpoint.rs"] +mod tests; diff --git a/src/common/mod.rs b/src/common/mod.rs index 9fc47cd..c6fff61 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -6,6 +6,7 @@ mod alpn; mod config; mod datagram; +mod endpoint; mod latency; mod lifecycle; mod logger; @@ -13,24 +14,28 @@ mod network; pub(crate) mod socks; mod tls; -pub(crate) use alpn::{MUX_MARKER, parse_alpn}; +pub(crate) use alpn::MUX_MARKER; pub(crate) use config::first_raw_query_value; pub use config::{ - DEFAULT_DIALER_IP, DEFAULT_MAX_TCP_FLOWS, DEFAULT_MAX_UDP_FLOWS, DEFAULT_RATE_LIMIT, - DEFAULT_TELEMETRY_INTERVAL, MAX_TELEMETRY_INTERVAL, MIN_TELEMETRY_INTERVAL, env_duration, - env_int, flow_setup_timeout, handshake_timeout, init_dialer_ip, max_tcp_flows, max_udp_flows, - mix_fallback_timeout, query_first, rate_limit_bytes_per_second, reload_interval, - report_interval, service_cooldown, shutdown_timeout, tcp_data_buf_size, tcp_dial_timeout, - tcp_read_timeout, telemetry_interval, udp_data_buf_size, udp_dial_timeout, udp_idle_timeout, + DEFAULT_DIALER_IP, DEFAULT_RATE_LIMIT, DEFAULT_TELEMETRY_INTERVAL, MAX_TELEMETRY_INTERVAL, + MIN_TELEMETRY_INTERVAL, env_duration, env_int, flow_setup_timeout, handshake_timeout, + init_dialer_ip, mix_fallback_timeout, query_first, rate_limit_bytes_per_second, + reload_interval, report_interval, service_cooldown, shutdown_timeout, tcp_data_buf_size, + tcp_dial_timeout, tcp_read_timeout, telemetry_interval, udp_data_buf_size, udp_dial_timeout, + udp_idle_timeout, }; pub(crate) use datagram::{ BudgetedDatagram, UdpDatagramSend, reserve_udp_budget, send_quic_udp_packet, }; +pub use endpoint::validate_endpoint_url_input; +pub(crate) use endpoint::{AddressFamily, CarrierEndpoint, ServiceEndpoint}; pub(crate) use latency::{LatencyGuard, LatencyTracker}; pub(crate) use lifecycle::{LifeMode, LifeReason, LifeState, Lifecycle, ShutdownSignals}; pub use logger::{LogLevel, Logger}; pub use network::{bind_udp_addrs, dial_tcp_from_local_ip, dial_udp_from_local_ip}; -pub(crate) use network::{filter_addrs, parse_local_ip}; +pub(crate) use network::{ + dial_tcp_from_local_ip_family, filter_addrs_for_family, parse_local_ip, resolve_bind_addrs, +}; pub(crate) use socks::{OutboundDialer, OutboundTcpStream, OutboundUdpSocket, SocksConfig}; pub(crate) use tls::certificate_sha256; pub(crate) use tls::new_server_configs_with_reload_interval; diff --git a/src/common/network.rs b/src/common/network.rs index 5153622..0c187a1 100644 --- a/src/common/network.rs +++ b/src/common/network.rs @@ -9,7 +9,40 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow}; use tokio::net::{TcpSocket, TcpStream, UdpSocket, lookup_host}; -use super::DEFAULT_DIALER_IP; +use super::{AddressFamily, CarrierEndpoint, DEFAULT_DIALER_IP}; + +/// Resolves every matching listen address for one carrier and removes duplicates. +pub(crate) fn resolve_bind_addrs(host: &str, endpoint: CarrierEndpoint) -> Result> { + let mut addrs = if host == "*" || host.is_empty() { + match endpoint.family { + AddressFamily::Any => vec![ + SocketAddr::from(([0, 0, 0, 0], endpoint.port)), + SocketAddr::from(([0u16; 8], endpoint.port)), + ], + AddressFamily::V4 => vec![SocketAddr::from(([0, 0, 0, 0], endpoint.port))], + AddressFamily::V6 => vec![SocketAddr::from(([0u16; 8], endpoint.port))], + } + } else if let Ok(ip) = host.parse::() { + if endpoint.family.accepts(ip) { + vec![SocketAddr::new(ip, endpoint.port)] + } else { + Vec::new() + } + } else { + let joined = format!("{host}:{}", endpoint.port); + joined + .to_socket_addrs() + .with_context(|| format!("failed to resolve listen address: {joined}"))? + .filter(|addr| endpoint.family.accepts(addr.ip())) + .collect() + }; + addrs.sort_unstable(); + addrs.dedup(); + if addrs.is_empty() { + return Err(anyhow!("no matching listen address resolved for {host}")); + } + Ok(addrs) +} /// Resolves the UDP listen addresses for a host/port pair. /// @@ -42,6 +75,15 @@ pub async fn dial_tcp_from_local_ip( dialer_ip: &str, target: &str, timeout: Duration, +) -> Result { + dial_tcp_from_local_ip_family(dialer_ip, target, timeout, AddressFamily::Any).await +} + +pub(crate) async fn dial_tcp_from_local_ip_family( + dialer_ip: &str, + target: &str, + timeout: Duration, + family: AddressFamily, ) -> Result { let connect = async { let local_ip = parse_local_ip(dialer_ip); @@ -50,15 +92,16 @@ pub async fn dial_tcp_from_local_ip( format!("common::util::dial_tcp_from_local_ip: failed to resolve target: {target}") })?; - for addr in filter_addrs(addrs, local_ip) { + for addr in filter_addrs_for_family(addrs, local_ip, family) { match connect_tcp_addr(local_ip, addr).await { Ok(stream) => return Ok(stream), Err(err) => last_err = Some(err), } } - Err(last_err - .unwrap_or_else(|| anyhow!("common::util::dial_tcp_from_local_ip: no target address"))) + Err(last_err.unwrap_or_else(|| { + anyhow!("common::util::dial_tcp_from_local_ip: no target address matches configured address family") + })) }; tokio::time::timeout(timeout, connect) @@ -117,6 +160,17 @@ pub(crate) fn filter_addrs( .collect() } +pub(crate) fn filter_addrs_for_family( + addrs: impl Iterator, + local_ip: Option, + family: AddressFamily, +) -> Vec { + filter_addrs(addrs, local_ip) + .into_iter() + .filter(|addr| family.accepts(addr.ip())) + .collect() +} + pub(super) async fn connect_tcp_addr( local_ip: Option, target: SocketAddr, diff --git a/src/common/socks/config.rs b/src/common/socks/config.rs index 4b219a1..07e10a6 100644 --- a/src/common/socks/config.rs +++ b/src/common/socks/config.rs @@ -90,13 +90,13 @@ pub(crate) fn parse_socks_value(raw_value: &str) -> Result<(String, Option Result<(String, u16)> { let (host, raw_port) = if let Some(rest) = value.strip_prefix('[') { - let end = rest.find(']').ok_or_else(|| { - anyhow!("common::socks::parse_host_port: invalid {name}: missing ']'") - })?; + let end = rest + .find(']') + .ok_or_else(|| anyhow!("invalid {name}: missing closing ']'"))?; let host = &rest[..end]; - let port = rest[end + 1..].strip_prefix(':').ok_or_else(|| { - anyhow!("common::socks::parse_host_port: invalid {name}: missing port") - })?; + let port = rest[end + 1..] + .strip_prefix(':') + .ok_or_else(|| anyhow!("invalid {name}: expected ':' followed by a port"))?; if host.parse::().is_err() { - bail!("common::socks::parse_host_port: invalid {name}: bracketed host must be IPv6"); + bail!("invalid {name}: brackets may only contain an IPv6 address"); } (host, port) } else { - let (host, port) = value.rsplit_once(':').ok_or_else(|| { - anyhow!("common::socks::parse_host_port: invalid {name}: missing port") - })?; + let (host, port) = value + .rsplit_once(':') + .ok_or_else(|| anyhow!("invalid {name}: expected HOST:PORT"))?; if host.contains(':') { - bail!("common::socks::parse_host_port: invalid {name}: IPv6 requires brackets"); + bail!("invalid {name}: IPv6 addresses must be enclosed in brackets"); } (host, port) }; if host.is_empty() && !allow_empty_host { - bail!("common::socks::parse_host_port: invalid {name}: empty host"); + bail!("invalid {name}: host must not be empty"); } let port = raw_port .parse::() .ok() .filter(|port| *port != 0) - .ok_or_else(|| anyhow!("common::socks::parse_host_port: invalid {name}: invalid port"))?; + .ok_or_else(|| anyhow!("invalid {name}: port must be in 1..=65535"))?; Ok((host.to_string(), port)) } @@ -157,7 +157,7 @@ pub(crate) fn format_host_port(host: &str, port: u16) -> String { fn validate_credential(name: &str, value: &str) -> Result<()> { if !(1..=u8::MAX as usize).contains(&value.len()) { - bail!("common::socks::validate_credential: {name} length must be 1..255 bytes"); + bail!("socks {name} length must be in 1..=255 bytes"); } Ok(()) } @@ -167,9 +167,7 @@ fn validate_raw_credential(value: &str) -> Result<()> { .bytes() .any(|byte| b":/?#[]@!$&'()*+,;=".contains(&byte)) { - bail!( - "common::socks::validate_raw_credential: reserved credentials must be percent-encoded" - ); + bail!("reserved characters in socks credentials must be percent-encoded"); } Ok(()) } @@ -178,7 +176,7 @@ fn decode_component(raw: &str, name: &str) -> Result { validate_percent_encoding(raw, name)?; percent_decode_str(raw) .decode_utf8() - .with_context(|| format!("common::socks::decode_component: invalid UTF-8 in {name}")) + .with_context(|| format!("invalid UTF-8 in {name}")) .map(|value| value.into_owned()) } @@ -191,9 +189,7 @@ fn validate_percent_encoding(raw: &str, name: &str) -> Result<()> { || !bytes[index + 1].is_ascii_hexdigit() || !bytes[index + 2].is_ascii_hexdigit() { - bail!( - "common::socks::validate_percent_encoding: invalid percent encoding in {name}" - ); + bail!("invalid percent encoding in {name}"); } index += 3; } else { diff --git a/src/common/tls.rs b/src/common/tls.rs index 90ac2c6..9a1f059 100644 --- a/src/common/tls.rs +++ b/src/common/tls.rs @@ -15,6 +15,8 @@ use quinn::crypto::rustls::QuicServerConfig; use rustls::crypto::ring; use url::Url; +use crate::protocol::ALPN; + pub(crate) use self::tls_cert::certificate_sha256; use self::tls_cert::{ReloadingCertResolver, new_self_signed_cert}; use super::{Logger, query_first, reload_interval}; @@ -44,18 +46,16 @@ impl fmt::Display for TLSMode { } } -/// Builds rustls and QUIC TLS server configuration for the configured ALPN. +/// Builds rustls and QUIC TLS server configuration for supported protocol versions. pub fn new_server_configs( parsed_url: &Url, - alpn: &str, logger: Logger, ) -> Result<(TLSMode, Arc, quinn::ServerConfig)> { - new_server_configs_with_reload_interval(parsed_url, alpn, reload_interval(), logger) + new_server_configs_with_reload_interval(parsed_url, reload_interval(), logger) } pub(crate) fn new_server_configs_with_reload_interval( parsed_url: &Url, - alpn: &str, reload_interval: Duration, logger: Logger, ) -> Result<(TLSMode, Arc, quinn::ServerConfig)> { @@ -109,7 +109,7 @@ pub(crate) fn new_server_configs_with_reload_interval( server_crypto.max_early_data_size = 0; server_crypto.send_half_rtt_data = false; - server_crypto.alpn_protocols = vec![alpn.as_bytes().to_vec()]; + server_crypto.alpn_protocols = vec![ALPN.to_vec()]; let quic_crypto = QuicServerConfig::try_from(server_crypto.clone()) .map_err(|e| anyhow!("common::tls::new_server_configs: QUIC TLS config failed: {e}"))?; logger.event(format_args!("CERT_SHA256|{cert_sha256}")); diff --git a/src/main.rs b/src/main.rs index edc3e80..0ef88d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use std::env; use std::io::IsTerminal; use anyhow::{Context, Result, bail}; -use nowhere::common::{LogLevel, Logger, query_first}; +use nowhere::common::{LogLevel, Logger, query_first, validate_endpoint_url_input}; use nowhere::portal::Portal; use nowhere::vector::Vector; use url::{ParseError, Url}; @@ -30,20 +30,23 @@ Commands: Portal URL: portal://@:[?] + portal://@/:[/:] Vector URL: vector://@:?socks=[&] + vector://@/:[/:]?socks=... Examples: - nowhere 'portal://secret@:2077' - nowhere 'portal://secret@0.0.0.0:2077?log=info&net=tcp' - nowhere 'portal://secret@:2077?tls=2&crt=/etc/nowhere/cert.pem&key=/etc/nowhere/key.pem' - nowhere 'portal://secret@:2077?socks=user:pass@127.0.0.1:1080' - nowhere 'portal://relay-key@:2077?next=upstream-key@origin.example:2077' - nowhere 'portal://relay-key@:2077?next=upstream-key@origin.example:2077&up=tcp&down=tcp' - nowhere 'portal://secret@:2077?rate=100&etar=200' - nowhere 'vector://secret@relay.example:2077?sni=relay.example&socks=127.0.0.1:1080' - nowhere 'vector://secret@127.0.0.1:2077?up=tcp&down=tcp&socks=:1080' + nowhere 'portal://secret@:2000' + nowhere 'portal://secret@*/tcp4:2006?log=info' + nowhere 'portal://secret@*/tcp:2006/udp:2017' + nowhere 'portal://secret@:2000?tls=2&crt=/etc/nowhere/cert.pem&key=/etc/nowhere/key.pem' + nowhere 'portal://secret@:2000?socks=user:pass@127.0.0.1:1080' + nowhere 'portal://relay-key@:2000?next=upstream-key@origin.example:2000' + nowhere 'portal://relay-key@:2000?next=upstream-key@origin.example:2000&up=tcp&down=tcp' + nowhere 'portal://secret@:2000?rate=100&etar=200' + nowhere 'vector://secret@relay.example:2000?sni=relay.example&socks=127.0.0.1:1080' + nowhere 'vector://secret@127.0.0.1:2000?up=tcp&down=tcp&socks=:1080' Required URL parts: shared-key Non-empty URL username. Percent-encode reserved characters. @@ -51,29 +54,29 @@ Required URL parts: Password credentials are not supported. Listen host: - empty Bind IPv4 and IPv6 wildcard sockets. + * Bind IPv4 and IPv6 wildcard sockets as allowed by carrier. + empty Compact form only; equivalent to *. 0.0.0.0 Bind IPv4 wildcard only. [::] Bind IPv6 wildcard only. - IP or hostname Bind the resolved listen address. + IP or hostname Bind all matching resolved listen addresses. Portal parameters: - net=mix|tcp|udp Listener mode. Default: mix. tls=1|2 TLS mode. 1 for RAM certificate; 2 for PEM files. Default: 1. tls=0 is not supported. crt= PEM certificate chain for tls=2. key= PEM private key for tls=2. - alpn= Exact TLS/QUIC ALPN. Default: now/1. rate= Client-to-target traffic limit. 0 disables it. etar= Target-to-client traffic limit. 0 disables it. dial= Local source IP for outbound target connections. Default: auto. socks= SOCKS5 outbound proxy: host:port or user:pass@host:port. Omit or use none to disable. - next= Native upstream Portal: shared-key@host:port. Omit or use + next= Native upstream Portal using the same endpoint grammar. + Example: shared-key@host/tcp:2006/udp6:2017. Omit or use none to disable. Mutually exclusive with socks. up=tcp|udp|mix Native upstream upload carrier. Mix chooses per flow. - Default: udp. + Defaults to the only declared carrier, or TCP. down=tcp|udp|mix Native upstream download carrier. Mix chooses per flow. - Default: udp. + Defaults to the only declared carrier, or TCP. mux=0|1 Use TLS Mux when the native route can select TCP. Default: 0. sni= Native upstream certificate DNS name. Default: none. pin= Native upstream certificate fingerprint. Default: none. @@ -81,9 +84,8 @@ Portal parameters: log= none, debug, info, warn, error, event. Default: info. Vector parameters: - up=tcp|udp|mix Upload carrier. Mix chooses per flow. Default: udp. - down=tcp|udp|mix Download carrier. Mix chooses per flow. Default: udp. - alpn= Exact TLS/QUIC ALPN. Default: now/1. + up=tcp|udp|mix Upload carrier. Defaults to the only declared carrier, or TCP. + down=tcp|udp|mix Download carrier. Defaults to the only declared carrier, or TCP. mux=0|1 Use TLS Mux when either direction can select TCP. Default: 0. sni= Verify the certificate for a DNS name. Empty, omitted, or none disables certificate validation. Default: none. @@ -98,6 +100,29 @@ Vector parameters: Query handling: Unknown parameters are ignored. If a parameter appears more than once, only its first value is used. Missing optional parameters use their defaults. + The net parameter is ignored; carrier paths select listeners. + +Carrier endpoint grammar: + tcp, udp Do not restrict the address family. + tcp4, udp4 Use IPv4 only. + tcp6, udp6 Use IPv6 only. + host:port Shorthand for TCP and UDP on the same port. + Explicit paths enable only their listed carriers. TCP and UDP share the host + but may use independent ports and address families. Carrier order is ignored; + effective configuration prints TCP before UDP. + Do not combine an authority port with carrier paths. Empty or trailing path + segments, duplicate or unknown carriers, family conflicts, and port 0 fail. + +Portal binding: + An unrestricted * carrier opens separate IPv4 and IPv6 wildcard sockets. + IPv6 listeners are V6ONLY. Hostnames resolve once at startup and bind every + matching address. Each declared carrier must bind at least one address. + Only an unavailable family on unrestricted * may degrade with a warning. + +Vector and next dialing: + * is invalid. DNS results are filtered independently for each carrier family. + A single carrier is the default for both directions; with both, TCP is the + default. Explicit up/down must exist, and mix requires both carriers. Transport capabilities: TLS/TCP TCP relay and UDP-over-TCP (UoT). @@ -114,11 +139,8 @@ SOCKS5 inbound: SOCKS5 UDP fragmentation is not supported. Environment: - NOW_MAX_TCP_FLOWS TCP flows per authenticated client session. - NOW_MAX_UDP_FLOWS UDP flows per authenticated client session. + NOW_TRANSPORT_MEMORY_PROFILE memory, balanced, or throughput. Default: throughput. NOW_QUIC_UDP_QUEUE_BYTES Maximum queued/reassembling UDP bytes per QUIC connection. - NOW_QUIC_MEMORY_PROFILE memory, balanced, or throughput. Default: throughput. - NOW_MAX_PENDING_PAIRS Maximum pending logical-flow IDs per session. NOW_FLOW_PAIR_TIMEOUT Timeout for completing a split logical flow. NOW_FLOW_SETUP_TIMEOUT Timeout for waiting for a logical flow to become ready. NOW_MIX_FALLBACK_TIMEOUT Primary Mix route preparation budget. Default: 1s. @@ -139,22 +161,21 @@ Environment: #[tokio::main] async fn main() { if let Err(err) = start(env::args().collect()).await { - eprintln!( - "nowhere-{VERSION} {}/{} pid={} error={err:#}", - env::consts::OS, - env::consts::ARCH, - std::process::id(), - ); + eprintln!("{}", format_start_error(&err)); std::process::exit(1); } } +fn format_start_error(error: &anyhow::Error) -> String { + format!("error: {error:#}") +} + async fn start(args: Vec) -> Result<()> { if args.len() == 1 { return run_tui().await; } if args.len() > 2 { - bail!("main::start: expected exactly one configuration URL"); + bail!("expected exactly one configuration URL; run 'nowhere --help' for usage"); } match args[1].as_str() { @@ -174,35 +195,28 @@ async fn start(args: Vec) -> Result<()> { _ => {} } - let command_url = - parse_command_url(&args[1]).with_context(|| "main::start: failed to parse command URL")?; - let scheme = command_url.url.scheme().to_string(); + let command_url = parse_command_url(&args[1]).with_context(|| "invalid configuration URL")?; + let scheme = command_url.scheme().to_string(); if !matches!(scheme.as_str(), "portal" | "vector") { - bail!("main::start: unknown URL scheme: {scheme}"); + bail!("invalid configuration URL: scheme must be portal or vector, found {scheme:?}"); } // Startup only needs `log` here. Each role parses its own configuration, // including Portal's intentionally ignored upstream options when `next` // is disabled. - let query = query_first(&command_url.url, &["log"]) - .with_context(|| "main::start: invalid URL query")?; + let query = + query_first(&command_url, &["log"]).with_context(|| "invalid configuration URL query")?; let logger = init_logger(query.get("log").map(String::as_str))?; match scheme.as_str() { "portal" => { - let portal = Portal::new_with_listen_host( - command_url.url, - command_url.listen_host.as_deref(), - logger, - ) - .with_context(|| "main::start: failed to create portal")?; + let portal = Portal::new(command_url, logger)?; portal.run().await } "vector" => { - let vector = Vector::new(command_url.url, logger) - .with_context(|| "main::start: failed to create vector")?; + let vector = Vector::new(command_url, logger)?; vector.run().await } - _ => bail!("main::start: unknown URL scheme: {}", scheme), + _ => unreachable!("scheme was validated above"), } } @@ -221,31 +235,22 @@ fn print_help() { ); } -struct CommandUrl { - url: Url, - listen_host: Option, -} - -fn parse_command_url(raw: &str) -> Result { +fn parse_command_url(raw: &str) -> Result { + validate_endpoint_url_input(raw, "endpoint")?; match Url::parse(raw) { - Ok(url) => Ok(CommandUrl { - url, - listen_host: None, - }), + Ok(url) => Ok(url), Err(ParseError::EmptyHost) => { - let normalized = normalize_empty_portal_host(raw) + let normalized = normalize_legacy_empty_portal_host(raw) .ok_or(ParseError::EmptyHost) .and_then(|url| Url::parse(&url))?; - Ok(CommandUrl { - url: normalized, - listen_host: Some(String::new()), - }) + Ok(normalized) } Err(err) => Err(err.into()), } } -fn normalize_empty_portal_host(raw: &str) -> Option { +/// Converts the V1 compact wildcard alias into the canonical V2 host form. +fn normalize_legacy_empty_portal_host(raw: &str) -> Option { let prefix = "portal://"; let rest = raw.strip_prefix(prefix)?; let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len()); @@ -257,10 +262,10 @@ fn normalize_empty_portal_host(raw: &str) -> Option { return None; } - let mut normalized = String::with_capacity(raw.len() + "localhost".len()); + let mut normalized = String::with_capacity(raw.len() + 1); normalized.push_str(prefix); normalized.push_str(&authority[..host_port_start]); - normalized.push_str("localhost"); + normalized.push('*'); normalized.push_str(host_port); normalized.push_str(suffix); Some(normalized) @@ -287,7 +292,9 @@ fn init_logger(level: Option<&str>) -> Result { logger.set_log_level(LogLevel::Event); logger.event(format_args!("main::init_logger: log level set to EVENT")); } - Some(value) => bail!("main::init_logger: invalid log level: {value}"), + Some(value) => { + bail!("log must be none, debug, info, warn, error, or event; found {value:?}") + } } Ok(logger) } diff --git a/src/mux/driver.rs b/src/mux/driver.rs index b38f76c..89bf46d 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -1,31 +1,30 @@ // Copyright (C) 2026 NodePassProject // SPDX-License-Identifier: GPL-3.0-only -use std::io; -use std::io::IoSlice; +use std::io::{self, IoSlice}; use std::sync::Arc; -use super::wire::{ - FLAG_FIN, FLAG_RST, FLAG_SYN, FlowId, FrameHeader, FrameKind, HEADER_LEN, decode_header, - encode_header, -}; use bytes::Bytes; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::sync::mpsc; -use super::{Inbound, Outbound, Shared}; +use super::wire::{ + CLOSE_FIN, FlowId, FrameHeader, FrameKind, HEADER_LEN, decode_header, encode_header, +}; +use super::{Inbound, MuxChunk, Outbound, Shared}; pub(super) async fn send_data( shared: Arc, flow_id: FlowId, - payload: Bytes, + payload: MuxChunk, ) -> io::Result<()> { let charge = frame_charge(payload.len()); - let (flow_credit, fair_credit) = shared.send_credits(flow_id)?; - let fair = fair_credit - .acquire_many_owned(charge as u32) - .await - .map_err(|_| closed())?; + let (flow_credit, slot) = { + let flows = shared.flows.lock().expect("mux flow lock"); + let flow = flows.get(&flow_id).ok_or_else(closed)?; + (flow.send_credit.clone(), flow.send_slot.clone()) + }; + let slot = slot.acquire_owned().await.map_err(|_| closed())?; let flow = flow_credit .acquire_many_owned(charge as u32) .await @@ -38,98 +37,116 @@ pub(super) async fn send_data( .map_err(|_| closed())?; shared .data_tx - .send(Outbound { - header: frame_stream(flow_id, 0, payload.len())?, + .send(Outbound::Data { + header: frame_data(flow_id, payload.len())?, payload, - flushed: None, + _slot: slot, }) .await .map_err(|_| closed())?; - fair.forget(); flow.forget(); connection.forget(); Ok(()) } pub(super) async fn run_reader(mut reader: R, shared: Arc) { - let result: io::Result<()> = async { + let operation = async { + let mut data_frames = 0_u8; loop { + if shared.closed.load(std::sync::atomic::Ordering::Acquire) { + return Ok(()); + } let mut encoded = [0; HEADER_LEN]; tokio::select! { - _ = shared.closed_notify.notified() => return Ok(()), + _ = shared.closed_notify.cancelled() => return Ok(()), result = reader.read_exact(&mut encoded) => { result?; } } let header = decode_header(&encoded).map_err(invalid)?; let payload_len = match header.kind { - FrameKind::Stream | FrameKind::Datagram => header.value as usize, - FrameKind::Window => 0, + FrameKind::Data => header.value as usize, + FrameKind::Open | FrameKind::Window | FrameKind::Fin | FrameKind::Reset => 0, }; let mut payload = vec![0; payload_len]; - if !payload.is_empty() { + if payload_len != 0 { tokio::select! { - _ = shared.closed_notify.notified() => return Ok(()), + _ = shared.closed_notify.cancelled() => return Ok(()), result = reader.read_exact(&mut payload) => { result?; } } } match header.kind { - FrameKind::Stream => receive_stream(&shared, header, Bytes::from(payload)).await?, + FrameKind::Open => receive_open(&shared, header).await?, + FrameKind::Data => receive_data(&shared, header, Bytes::from(payload)).await?, FrameKind::Window => receive_window(&shared, header)?, - FrameKind::Datagram => { - return Err(io::Error::new( - io::ErrorKind::Unsupported, - "mux datagram is not registered", - )); + FrameKind::Fin | FrameKind::Reset => receive_close(&shared, header).await, + } + if payload_len != 0 { + data_frames = data_frames.wrapping_add(1); + if data_frames == 32 { + data_frames = 0; + tokio::task::yield_now().await; } } } - } - .await; + }; + let result: io::Result<()> = tokio::select! { + biased; + _ = shared.closed_notify.cancelled() => return, + result = operation => result, + }; if result.is_err() { shared.close(); } } -async fn receive_stream( - shared: &Arc, - header: FrameHeader, - payload: Bytes, -) -> io::Result<()> { - if header.flags & FLAG_SYN != 0 { - let terminal_permit = shared.reserve_terminal().await?; - let stream = shared.insert_flow(header.flow_id, terminal_permit)?; - shared - .incoming_tx - .send(stream) - .await - .map_err(|_| closed())?; - } - if header.flags & FLAG_RST != 0 { - let flow = shared.remove_flow(header.flow_id); - if let Some(flow) = flow { - let _ = flow.inbound.send(Inbound::Reset).await; +async fn receive_open(shared: &Arc, header: FrameHeader) -> io::Result<()> { + let stream = shared.insert_flow(header.flow_id, true)?; + let extra_credit = header.value as usize; + if extra_credit != 0 { + let credit = shared.send_credit(header.flow_id)?; + if credit.available_permits().saturating_add(extra_credit) + > super::credit_units(super::MAX_STREAM_WINDOW_BYTES) + { + return Err(invalid("stream window overflow")); } - return Ok(()); + credit.add_permits(extra_credit); } - if !payload.is_empty() { - let charge = frame_charge(payload.len()); - let inbound = shared.admit_receive(header.flow_id, charge)?; - inbound - .send(Inbound::Data { payload, charge }) - .await - .map_err(|_| closed())?; + // RESET removes active flow state, but cannot remove an already queued + // stream. Bound pending delivery separately and never block the reader. + shared.incoming_tx.try_send(stream).map_err(|_| closed()) +} + +async fn receive_data(shared: &Arc, header: FrameHeader, payload: Bytes) -> io::Result<()> { + let charge = frame_charge(payload.len()); + let inbound = shared.admit_receive(header.flow_id, charge)?; + if inbound.send(Inbound::Data { payload, charge }).is_err() { + // The local read half may be abandoned while its writer is still + // live. Return credit for discarded bytes without killing other flows. + shared.release_receive(header.flow_id, charge); } - if header.flags & FLAG_FIN != 0 { - let inbound = shared - .flows - .lock() - .expect("mux flow lock") - .get(&header.flow_id) - .map(|flow| flow.inbound.clone()); - if let Some(inbound) = inbound { - let _ = inbound.send(Inbound::Fin).await; + Ok(()) +} + +async fn receive_close(shared: &Shared, header: FrameHeader) { + if header.kind == FrameKind::Reset { + if let Some(flow) = shared.remove_flow(header.flow_id) { + let _ = flow.inbound.send(Inbound::Reset); } + return; + } + let inbound = { + let mut flows = shared.flows.lock().expect("mux flow lock"); + flows.get_mut(&header.flow_id).and_then(|flow| { + if flow.remote_fin { + None + } else { + flow.remote_fin = true; + Some(flow.inbound.clone()) + } + }) + }; + if let Some(inbound) = inbound { + let _ = inbound.send(Inbound::Fin); } - Ok(()) } fn receive_window(shared: &Shared, header: FrameHeader) -> io::Result<()> { @@ -139,53 +156,46 @@ fn receive_window(shared: &Shared, header: FrameHeader) -> io::Result<()> { .connection_send_credit .available_permits() .saturating_add(credit) - > shared.config.connection_window_bytes + > super::credit_units(super::MAX_CONNECTION_WINDOW_BYTES) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "connection window overflow", - )); + return Err(invalid("connection window overflow")); } shared.connection_send_credit.add_permits(credit); + shared.connection_send_peak.fetch_max( + shared.connection_send_credit.available_permits(), + std::sync::atomic::Ordering::Relaxed, + ); return Ok(()); } let mut flows = shared.flows.lock().expect("mux flow lock"); let Some(flow) = flows.get_mut(&header.flow_id) else { - // Flow-close frames and their final credit updates can cross on the - // full-duplex carrier. A late stream-local WINDOW has no authority to - // change connection credit and is safe to ignore. return Ok(()); }; if flow.send_credit.available_permits().saturating_add(credit) - > shared.config.stream_window_bytes + > super::credit_units(super::MAX_STREAM_WINDOW_BYTES) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "stream window overflow", - )); + return Err(invalid("stream window overflow")); } flow.send_credit.add_permits(credit); - Shared::return_fair_credit(flow, credit); Ok(()) } pub(super) async fn run_terminals(shared: Arc, mut terminal_rx: mpsc::Receiver) { loop { + if shared.closed.load(std::sync::atomic::Ordering::Acquire) { + return; + } let flow_id = tokio::select! { - _ = shared.closed_notify.notified() => return, + _ = shared.closed_notify.cancelled() => return, flow_id = terminal_rx.recv() => flow_id, }; let Some(flow_id) = flow_id else { return }; - let Ok(header) = frame_stream(flow_id, FLAG_FIN, 0) else { + let Ok(header) = frame_close(flow_id, CLOSE_FIN) else { continue; }; let sent = tokio::select! { - _ = shared.closed_notify.notified() => return, - sent = shared.data_tx.send(Outbound { - header, - payload: Bytes::new(), - flushed: None, - }) => sent, + _ = shared.closed_notify.cancelled() => return, + sent = shared.data_tx.send(Outbound::Control(header)) => sent, }; if sent.is_err() { return; @@ -198,17 +208,20 @@ pub(super) async fn run_writer( shared: Arc, mut data_rx: mpsc::Receiver, ) { - let mut control = Vec::with_capacity(8 * 64); - let mut headers = Vec::with_capacity(8 * 256); + let mut control = Vec::with_capacity(HEADER_LEN * 64); + let mut headers = Vec::with_capacity(HEADER_LEN * 256); let mut pending_item = None; - let result: io::Result<()> = async { + let operation = async { loop { + if shared.closed.load(std::sync::atomic::Ordering::Acquire) { + return Ok(()); + } let item = if let Some(item) = pending_item.take() { Some(item) } else { tokio::select! { biased; - _ = shared.closed_notify.notified() => return Ok(()), + _ = shared.closed_notify.cancelled() => return Ok(()), _ = shared.control_notify.notified() => { write_pending_windows(&mut writer, &shared, &mut control).await?; continue; @@ -217,33 +230,50 @@ pub(super) async fn run_writer( } }; let Some(item) = item else { return Ok(()) }; - if item.flushed.is_some() && item.payload.is_empty() && item.header.flags == 0 { - let result = writer.flush().await; - if let Some(done) = item.flushed { + match item { + Outbound::Flush(done) => { + let result = writer.flush().await; + let failed = result.is_err(); let _ = done.send(result); + if failed { + return Err(closed()); + } } - continue; - } - if item.flushed.is_none() && item.payload.is_empty() { - headers.clear(); - headers.extend_from_slice(&encode_header(item.header).map_err(invalid)?); - while headers.len() < 8 * 256 { - let Ok(next) = data_rx.try_recv() else { break }; - if next.flushed.is_none() && next.payload.is_empty() { - headers.extend_from_slice(&encode_header(next.header).map_err(invalid)?); - } else { - pending_item = Some(next); - break; + Outbound::Control(header) => { + headers.clear(); + headers.extend_from_slice(&encode_header(header).map_err(invalid)?); + while headers.len() < HEADER_LEN * 256 { + let Ok(next) = data_rx.try_recv() else { break }; + match next { + Outbound::Control(header) => { + headers.extend_from_slice(&encode_header(header).map_err(invalid)?); + } + next => { + pending_item = Some(next); + break; + } + } } + writer.write_all(&headers).await?; + writer.flush().await?; + } + Outbound::Data { + header, + payload, + _slot, + } => { + let header = encode_header(header).map_err(invalid)?; + write_frame_vectored(&mut writer, &header, payload.as_ref()).await?; + drop(_slot); } - writer.write_all(&headers).await?; - continue; } - let header = encode_header(item.header).map_err(invalid)?; - write_frame_vectored(&mut writer, &header, &item.payload).await?; } - } - .await; + }; + let result: io::Result<()> = tokio::select! { + biased; + _ = shared.closed_notify.cancelled() => return, + result = operation => result, + }; if result.is_err() { shared.close(); } @@ -258,11 +288,12 @@ async fn write_frame_vectored( let mut payload_offset = 0; while header_offset != header.len() || payload_offset != payload.len() { let written = if header_offset != header.len() { - let buffers = [ - IoSlice::new(&header[header_offset..]), - IoSlice::new(&payload[payload_offset..]), - ]; - writer.write_vectored(&buffers).await? + writer + .write_vectored(&[ + IoSlice::new(&header[header_offset..]), + IoSlice::new(&payload[payload_offset..]), + ]) + .await? } else { writer.write(&payload[payload_offset..]).await? }; @@ -316,6 +347,7 @@ async fn write_pending_windows( } if !encoded.is_empty() { writer.write_all(encoded).await?; + writer.flush().await?; } Ok(()) } @@ -331,11 +363,20 @@ fn append_windows(encoded: &mut Vec, flow_id: FlowId, mut credit: usize) -> } fn frame_charge(payload: usize) -> usize { - payload + super::credit_units(payload) +} + +pub(super) fn frame_open(flow_id: FlowId, receive_window_bytes: usize) -> io::Result { + let extra = receive_window_bytes.saturating_sub(super::BASE_STREAM_WINDOW_BYTES); + FrameHeader::open(flow_id, super::credit_units(extra)).map_err(invalid) +} + +pub(super) fn frame_data(flow_id: FlowId, length: usize) -> io::Result { + FrameHeader::data(flow_id, length).map_err(invalid) } -pub(super) fn frame_stream(flow_id: FlowId, flags: u8, length: usize) -> io::Result { - FrameHeader::stream(flow_id, flags, length).map_err(invalid) +pub(super) fn frame_close(flow_id: FlowId, code: u8) -> io::Result { + FrameHeader::close(flow_id, code).map_err(invalid) } fn invalid(error: impl std::fmt::Display) -> io::Error { diff --git a/src/mux/handle.rs b/src/mux/handle.rs index 4faffa3..9f7ebab 100644 --- a/src/mux/handle.rs +++ b/src/mux/handle.rs @@ -9,12 +9,10 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use bytes::Bytes; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::{Notify, Semaphore, mpsc, watch}; -use super::driver::{closed, frame_stream, run_reader, run_terminals, run_writer}; -use super::wire::FLAG_SYN; +use super::driver::{closed, frame_open, run_reader, run_terminals, run_writer}; use super::{Incoming, MuxConfig, MuxHandle, MuxStream, Outbound, Shared}; impl MuxHandle { @@ -24,28 +22,44 @@ impl MuxHandle { { let config = config.validate()?; let (data_tx, data_rx) = mpsc::channel(config.outbound_frames); - let (terminal_tx, terminal_rx) = mpsc::channel(config.max_streams); - let (incoming_tx, incoming_rx) = mpsc::channel(config.max_streams); + let (terminal_tx, terminal_rx) = mpsc::channel(config.active_stream_limit); + let (incoming_tx, incoming_rx) = mpsc::channel(config.active_stream_limit); let (active_streams_tx, _) = watch::channel(0); let shared = Arc::new(Shared { config, flows: Mutex::new(HashMap::new()), - connection_send_credit: Arc::new(Semaphore::new(config.connection_window_bytes)), - connection_receive_credit: Mutex::new(config.connection_window_bytes), - pending_connection_credit: AtomicUsize::new(0), - ready_flows: Mutex::new(VecDeque::with_capacity(config.max_streams)), + connection_send_credit: Arc::new(Semaphore::new(super::credit_units( + super::BASE_CONNECTION_WINDOW_BYTES, + ))), + connection_send_peak: AtomicUsize::new(super::credit_units( + super::BASE_CONNECTION_WINDOW_BYTES, + )), + connection_receive_credit: Mutex::new(super::credit_units( + config.connection_window_bytes, + )), + pending_connection_credit: AtomicUsize::new(super::credit_units( + config + .connection_window_bytes + .saturating_sub(super::BASE_CONNECTION_WINDOW_BYTES), + )), + ready_flows: Mutex::new(VecDeque::new()), data_tx, terminal_tx, control_notify: Notify::new(), incoming_tx, active_streams_tx, closed: AtomicBool::new(false), - closed_notify: Notify::new(), + closed_notify: tokio_util::sync::CancellationToken::new(), + #[cfg(test)] + borrowed_write_copies: AtomicUsize::new(0), }); let (reader, writer) = tokio::io::split(io); tokio::spawn(run_reader(reader, shared.clone())); tokio::spawn(run_writer(writer, shared.clone(), data_rx)); tokio::spawn(run_terminals(shared.clone(), terminal_rx)); + if config.connection_window_bytes > super::BASE_CONNECTION_WINDOW_BYTES { + shared.control_notify.notify_one(); + } Ok(( Self { shared }, Incoming { @@ -54,16 +68,24 @@ impl MuxHandle { )) } + #[cfg(test)] pub(crate) async fn open_stream(&self, flow_id: super::FlowId) -> io::Result { - let terminal_permit = self.shared.reserve_terminal().await?; - let stream = self.shared.insert_flow(flow_id, terminal_permit)?; + let stream = self.prepare_stream(flow_id)?; + self.open_prepared(stream).await + } + + pub(crate) fn prepare_stream(&self, flow_id: super::FlowId) -> io::Result { + self.shared.insert_flow(flow_id, false) + } + + pub(crate) async fn open_prepared(&self, stream: MuxStream) -> io::Result { + let flow_id = stream.flow_id(); self.shared .data_tx - .send(Outbound { - header: frame_stream(flow_id, FLAG_SYN, 0)?, - payload: Bytes::new(), - flushed: None, - }) + .send(Outbound::Control(frame_open( + flow_id, + self.shared.config.stream_window_bytes, + )?)) .await .map_err(|_| closed())?; Ok(stream) @@ -77,10 +99,41 @@ impl MuxHandle { self.shared.flows.lock().expect("mux flow lock").len() } + pub(crate) fn pressure(&self) -> usize { + let available = self.shared.connection_send_credit.available_permits(); + let peak = self.shared.connection_send_peak.load(Ordering::Relaxed); + let receive = *self + .shared + .connection_receive_credit + .lock() + .expect("mux credit lock"); + let receive_peak = super::credit_units(self.shared.config.connection_window_bytes); + let queue = self.shared.config.outbound_frames; + // Fixed-point occupancy; no per-frame timestamps or flow scans. + let occupancy = + |free: usize, total: usize| total.saturating_sub(free) * 1024 / total.max(1); + occupancy(available, peak) + .max(occupancy(receive, receive_peak)) + .max(occupancy(self.shared.data_tx.capacity(), queue)) + } + + #[cfg(test)] + pub(crate) fn borrowed_write_copies(&self) -> usize { + self.shared.borrowed_write_copies.load(Ordering::Relaxed) + } + + #[cfg(test)] + pub(crate) fn reset_borrowed_write_copies(&self) { + self.shared + .borrowed_write_copies + .store(0, Ordering::Relaxed); + } + pub(crate) fn close(&self) { self.shared.close(); } + #[cfg(test)] pub(crate) fn same_carrier(&self, other: &Self) -> bool { Arc::ptr_eq(&self.shared, &other.shared) } @@ -116,7 +169,7 @@ impl MuxHandle { if self.is_closed() { return; } - self.shared.closed_notify.notified().await; + self.shared.closed_notify.cancelled().await; } } diff --git a/src/mux/mod.rs b/src/mux/mod.rs index c54424e..19602c8 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -20,39 +20,57 @@ mod handle; mod stream; mod wire; -const FRAME_BYTES: usize = 32 * 1024; -const WINDOW_UPDATE_BYTES: usize = 4 * 1024; -// Frame count is separate from the byte window: UoT carries many small -// packets, so the frame queue absorbs scheduling bursts while the byte window -// remains the hard payload bound. -const FLOW_CHANNEL_FRAMES: usize = 512; -const MIN_FAIR_CREDIT_BYTES: usize = 256 * 1024; +pub(crate) const FRAME_BYTES: usize = 32 * 1024; +const MIB: usize = 1024 * 1024; +const BASE_STREAM_WINDOW_BYTES: usize = 4 * MIB; +const BASE_CONNECTION_WINDOW_BYTES: usize = 8 * MIB; +const MAX_STREAM_WINDOW_BYTES: usize = 16 * MIB; +const MAX_CONNECTION_WINDOW_BYTES: usize = 32 * MIB; +const CREDIT_UNIT_BYTES: usize = 1024; +const WINDOW_UPDATE_DIVISOR: usize = 8; +const ACTIVE_STREAM_RESOURCE_LIMIT: usize = 4096; pub(crate) const MUX_IDLE_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct MuxConfig { pub stream_window_bytes: usize, pub connection_window_bytes: usize, - pub max_streams: usize, + pub active_stream_limit: usize, pub outbound_frames: usize, } impl Default for MuxConfig { fn default() -> Self { - Self { - stream_window_bytes: 512 * 1024, - connection_window_bytes: 512 * 1024, - max_streams: 256, - outbound_frames: 512, - } + Self::from_flow_control(crate::transport::transport_flow_control().unwrap_or( + crate::transport::TransportFlowControl { + stream_receive_window: MAX_STREAM_WINDOW_BYTES as u32, + connection_receive_window: MAX_CONNECTION_WINDOW_BYTES as u32, + send_window: MAX_CONNECTION_WINDOW_BYTES as u64, + }, + )) } } impl MuxConfig { + pub(crate) fn from_flow_control(profile: crate::transport::TransportFlowControl) -> Self { + Self { + stream_window_bytes: profile.stream_receive_window as usize, + connection_window_bytes: profile.connection_receive_window as usize, + active_stream_limit: ACTIVE_STREAM_RESOURCE_LIMIT, + outbound_frames: 512, + } + } fn validate(self) -> io::Result { - if self.stream_window_bytes < FRAME_BYTES + if self.stream_window_bytes < BASE_STREAM_WINDOW_BYTES + || self.stream_window_bytes > MAX_STREAM_WINDOW_BYTES + || self.connection_window_bytes < BASE_CONNECTION_WINDOW_BYTES + || self.connection_window_bytes > MAX_CONNECTION_WINDOW_BYTES + || !self.stream_window_bytes.is_multiple_of(CREDIT_UNIT_BYTES) + || !self + .connection_window_bytes + .is_multiple_of(CREDIT_UNIT_BYTES) || self.connection_window_bytes < self.stream_window_bytes - || self.max_streams == 0 + || self.active_stream_limit == 0 || self.outbound_frames == 0 || self.connection_window_bytes > Semaphore::MAX_PERMITS { @@ -70,10 +88,21 @@ pub(crate) struct MuxStream { writer: FlowWriter, } +pub(crate) struct MuxChunk { + payload: Bytes, + _credit: Option, +} + +struct ReceiveCredit { + shared: Arc, + flow_id: FlowId, + charge: usize, +} + pub(crate) struct FlowReader { shared: Arc, flow_id: FlowId, - receiver: mpsc::Receiver, + receiver: mpsc::UnboundedReceiver, current: Option<(Bytes, usize, usize)>, eof: bool, } @@ -83,7 +112,6 @@ pub(crate) struct FlowWriter { flow_id: FlowId, pending: Option, pending_action: Option, - terminal_permit: Option>, closed: bool, } @@ -103,6 +131,7 @@ struct Shared { config: MuxConfig, flows: Mutex>, connection_send_credit: Arc, + connection_send_peak: AtomicUsize, connection_receive_credit: Mutex, pending_connection_credit: AtomicUsize, ready_flows: Mutex>, @@ -112,19 +141,20 @@ struct Shared { incoming_tx: mpsc::Sender, active_streams_tx: watch::Sender, closed: AtomicBool, - closed_notify: Notify, + closed_notify: tokio_util::sync::CancellationToken, + #[cfg(test)] + borrowed_write_copies: AtomicUsize, } struct FlowState { - inbound: mpsc::Sender, + inbound: mpsc::UnboundedSender, send_credit: Arc, - fair_send_credit: Arc, - fair_limit: usize, - fair_debt: usize, + send_slot: Arc, receive_credit: usize, pending_receive_credit: usize, window_queued: bool, local_parts: u8, + remote_fin: bool, } enum Inbound { @@ -133,35 +163,77 @@ enum Inbound { Reset, } -struct Outbound { - header: FrameHeader, - payload: Bytes, - flushed: Option>>, +enum Outbound { + Data { + header: FrameHeader, + payload: MuxChunk, + _slot: tokio::sync::OwnedSemaphorePermit, + }, + Control(FrameHeader), + Flush(oneshot::Sender>), } -impl Shared { - async fn reserve_terminal(&self) -> io::Result> { - tokio::select! { - _ = self.closed_notify.notified() => Err(closed()), - permit = self.terminal_tx.clone().reserve_owned() => permit.map_err(|_| closed()), +impl MuxChunk { + pub(crate) fn from_bytes(payload: Bytes) -> Self { + Self { + payload, + _credit: None, + } + } + + fn received(payload: Bytes, shared: Arc, flow_id: FlowId, charge: usize) -> Self { + Self { + payload, + _credit: Some(ReceiveCredit { + shared, + flow_id, + charge, + }), } } + pub(crate) fn len(&self) -> usize { + self.payload.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.payload.is_empty() + } +} + +impl AsRef<[u8]> for MuxChunk { + fn as_ref(&self) -> &[u8] { + &self.payload + } +} + +impl Drop for ReceiveCredit { + fn drop(&mut self) { + self.shared.release_receive(self.flow_id, self.charge); + } +} + +impl Shared { fn insert_flow( self: &Arc, flow_id: FlowId, - terminal_permit: mpsc::OwnedPermit, + advertise_window: bool, ) -> io::Result { - if flow_id == 0 || self.closed.load(Ordering::Acquire) { + if flow_id == 0 || flow_id > crate::protocol::MAX_FLOW_ID { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "flow ID is outside the 30-bit range", + )); + } + if self.closed.load(Ordering::Acquire) { return Err(closed()); } - let (sender, receiver) = mpsc::channel(FLOW_CHANNEL_FRAMES); + // DATA is bounded separately by byte credit. OPEN must have its own + // admission ceiling because it allocates flow metadata without DATA. + let (sender, receiver) = mpsc::unbounded_channel(); let mut flows = self.flows.lock().expect("mux flow lock"); - if flows.len() >= self.config.max_streams { - return Err(io::Error::new( - io::ErrorKind::WouldBlock, - "mux stream limit reached", - )); + if self.closed.load(Ordering::Acquire) { + return Err(closed()); } if flows.contains_key(&flow_id) { return Err(io::Error::new( @@ -169,26 +241,45 @@ impl Shared { "mux flow already exists", )); } - let send_credit = Arc::new(Semaphore::new(self.config.stream_window_bytes)); - let fair_send_credit = Arc::new(Semaphore::new(self.config.stream_window_bytes)); + if flows.len() >= self.config.active_stream_limit { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "mux active-stream resource limit reached", + )); + } + let send_credit = Arc::new(Semaphore::new(credit_units(BASE_STREAM_WINDOW_BYTES))); + let initial_credit = if advertise_window { + credit_units( + self.config + .stream_window_bytes + .saturating_sub(BASE_STREAM_WINDOW_BYTES), + ) + } else { + 0 + }; flows.insert( flow_id, FlowState { inbound: sender, send_credit, - fair_send_credit, - fair_limit: self.config.stream_window_bytes, - fair_debt: 0, - receive_credit: self.config.stream_window_bytes, - pending_receive_credit: 0, - window_queued: false, + send_slot: Arc::new(Semaphore::new(1)), + receive_credit: credit_units(self.config.stream_window_bytes), + pending_receive_credit: initial_credit, + window_queued: advertise_window && initial_credit != 0, local_parts: 2, + remote_fin: false, }, ); - Self::rebalance_fair_credits(&mut flows, self.config); let active_streams = flows.len(); - drop(flows); self.active_streams_tx.send_replace(active_streams); + drop(flows); + if advertise_window && initial_credit != 0 { + self.ready_flows + .lock() + .expect("mux ready-flow lock") + .push_back(flow_id); + self.control_notify.notify_one(); + } Ok(MuxStream { reader: FlowReader { shared: self.clone(), @@ -202,7 +293,6 @@ impl Shared { flow_id, pending: None, pending_action: None, - terminal_permit: Some(terminal_permit), closed: false, }, }) @@ -212,65 +302,45 @@ impl Shared { if self.closed.swap(true, Ordering::AcqRel) { return; } - self.flows.lock().expect("mux flow lock").clear(); + let mut flows = self.flows.lock().expect("mux flow lock"); + for flow in flows.values() { + flow.send_credit.close(); + flow.send_slot.close(); + } + flows.clear(); self.active_streams_tx.send_replace(0); - self.closed_notify.notify_waiters(); + drop(flows); + self.connection_send_credit.close(); + self.closed_notify.cancel(); } - fn send_credits(&self, flow_id: FlowId) -> io::Result<(Arc, Arc)> { + fn send_credit(&self, flow_id: FlowId) -> io::Result> { self.flows .lock() .expect("mux flow lock") .get(&flow_id) - .map(|flow| (flow.send_credit.clone(), flow.fair_send_credit.clone())) + .map(|flow| flow.send_credit.clone()) .ok_or_else(closed) } - fn rebalance_fair_credits(flows: &mut HashMap, config: MuxConfig) { - if flows.is_empty() { - return; - } - let fair_limit = (config.connection_window_bytes / flows.len()) - .max(MIN_FAIR_CREDIT_BYTES) - .min(config.stream_window_bytes); - for flow in flows.values_mut() { - if fair_limit < flow.fair_limit { - let reduction = flow.fair_limit - fair_limit; - let removed = flow.fair_send_credit.forget_permits(reduction); - flow.fair_debt = flow.fair_debt.saturating_add(reduction - removed); - } else if fair_limit > flow.fair_limit { - let increase = fair_limit - flow.fair_limit; - let debt_repaid = increase.min(flow.fair_debt); - flow.fair_debt -= debt_repaid; - flow.fair_send_credit.add_permits(increase - debt_repaid); - } - flow.fair_limit = fair_limit; - } - } - - fn return_fair_credit(flow: &mut FlowState, credit: usize) { - let debt_repaid = credit.min(flow.fair_debt); - flow.fair_debt -= debt_repaid; - let returned = credit - debt_repaid; - let room = flow - .fair_limit - .saturating_sub(flow.fair_send_credit.available_permits()); - flow.fair_send_credit.add_permits(returned.min(room)); - } - fn remove_flow(&self, flow_id: FlowId) -> Option { let mut flows = self.flows.lock().expect("mux flow lock"); let removed = flows.remove(&flow_id); - if removed.is_some() { - Self::rebalance_fair_credits(&mut flows, self.config); + if let Some(flow) = &removed { + flow.send_credit.close(); + flow.send_slot.close(); } let active_streams = flows.len(); - drop(flows); self.active_streams_tx.send_replace(active_streams); + drop(flows); removed } - fn admit_receive(&self, flow_id: FlowId, charge: usize) -> io::Result> { + fn admit_receive( + &self, + flow_id: FlowId, + charge: usize, + ) -> io::Result> { let mut connection = self .connection_receive_credit .lock() @@ -279,6 +349,12 @@ impl Shared { let flow = flows.get_mut(&flow_id).ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidData, "frame for unknown mux flow") })?; + if flow.remote_fin { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "DATA received after mux FIN", + )); + } if flow.receive_credit < charge || *connection < charge { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -301,12 +377,12 @@ impl Shared { .expect("mux credit lock"); *connection = connection .saturating_add(charge) - .min(self.config.connection_window_bytes); + .min(credit_units(self.config.connection_window_bytes)); if let Some(flow) = self.flows.lock().expect("mux flow lock").get_mut(&flow_id) { flow.receive_credit = flow .receive_credit .saturating_add(charge) - .min(self.config.stream_window_bytes); + .min(credit_units(self.config.stream_window_bytes)); flow.pending_receive_credit = flow.pending_receive_credit.saturating_add(charge); let ready = if flow.window_queued { false @@ -314,9 +390,12 @@ impl Shared { flow.window_queued = true; true }; - (ready, flow.pending_receive_credit >= WINDOW_UPDATE_BYTES) + let threshold = + credit_units(self.config.stream_window_bytes / WINDOW_UPDATE_DIVISOR) + .min(u16::MAX as usize); + (ready, flow.pending_receive_credit >= threshold) } else { - return; + (false, false) } }; if flow_ready { @@ -328,7 +407,9 @@ impl Shared { let previous = self .pending_connection_credit .fetch_add(charge, Ordering::AcqRel); - if flow_notify || previous.saturating_add(charge) >= WINDOW_UPDATE_BYTES { + let threshold = credit_units(self.config.connection_window_bytes / WINDOW_UPDATE_DIVISOR) + .min(u16::MAX as usize); + if flow_notify || previous.saturating_add(charge) >= threshold { self.control_notify.notify_one(); } } @@ -342,17 +423,20 @@ impl Shared { flow.local_parts = flow.local_parts.saturating_sub(1); if flow.local_parts == 0 { flows.remove(&flow_id); - Self::rebalance_fair_credits(&mut flows, self.config); } let active_streams = flows.len(); - drop(flows); self.active_streams_tx.send_replace(active_streams); + drop(flows); if flush_credit { self.control_notify.notify_one(); } } } +fn credit_units(bytes: usize) -> usize { + bytes.div_ceil(CREDIT_UNIT_BYTES) +} + #[cfg(test)] #[path = "../tests/mux/runtime.rs"] mod tests; diff --git a/src/mux/stream.rs b/src/mux/stream.rs index 31cf420..bd92780 100644 --- a/src/mux/stream.rs +++ b/src/mux/stream.rs @@ -6,13 +6,17 @@ use std::io::IoSlice; use std::pin::Pin; use std::task::{Context, Poll}; -use super::wire::FLAG_FIN; +use super::wire::CLOSE_FIN; use bytes::Bytes; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::sync::oneshot; -use super::driver::{closed, frame_stream, send_data}; -use super::{FRAME_BYTES, FlowReader, FlowWriter, Inbound, MuxStream, Outbound}; +use super::driver::{closed, frame_close, send_data}; +use super::{FRAME_BYTES, FlowReader, FlowWriter, Inbound, MuxChunk, MuxStream, Outbound}; + +fn copy_payload(payload: &[u8]) -> Bytes { + Bytes::copy_from_slice(payload) +} impl MuxStream { pub fn into_split(self) -> (FlowReader, FlowWriter) { @@ -106,8 +110,44 @@ impl AsyncRead for FlowReader { } } +impl FlowReader { + pub(crate) async fn recv_chunk(&mut self) -> io::Result> { + if let Some((payload, offset, charge)) = self.current.take() { + return Ok(Some(MuxChunk::received( + payload.slice(offset..), + self.shared.clone(), + self.flow_id, + charge, + ))); + } + if self.eof { + return Ok(None); + } + match self.receiver.recv().await { + Some(Inbound::Data { payload, charge }) => Ok(Some(MuxChunk::received( + payload, + self.shared.clone(), + self.flow_id, + charge, + ))), + Some(Inbound::Fin) | None => { + self.eof = true; + Ok(None) + } + Some(Inbound::Reset) => { + self.eof = true; + Err(io::Error::new( + io::ErrorKind::ConnectionReset, + "mux flow reset", + )) + } + } + } +} + impl Drop for FlowReader { fn drop(&mut self) { + self.receiver.close(); if let Some((_, _, charge)) = self.current.take() { self.shared.release_receive(self.flow_id, charge); } @@ -136,7 +176,11 @@ impl AsyncWrite for FlowWriter { return Poll::Ready(Ok(0)); } let length = buf.len().min(FRAME_BYTES); - let payload = Bytes::copy_from_slice(&buf[..length]); + #[cfg(test)] + self.shared + .borrowed_write_copies + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let payload = MuxChunk::from_bytes(copy_payload(&buf[..length])); self.start_write(cx, payload, length) } @@ -151,23 +195,18 @@ impl AsyncWrite for FlowWriter { if let Some(result) = self.poll_pending(cx) { return result; } - let length = bufs - .iter() - .map(|buffer| buffer.len()) - .sum::() - .min(FRAME_BYTES); - if length == 0 { - return Poll::Ready(Ok(0)); - } - let mut payload = Vec::with_capacity(length); for buffer in bufs { - let remaining = length - payload.len(); - if remaining == 0 { - break; + if !buffer.is_empty() { + let length = buffer.len().min(FRAME_BYTES); + #[cfg(test)] + self.shared + .borrowed_write_copies + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let payload = MuxChunk::from_bytes(copy_payload(&buffer[..length])); + return self.start_write(cx, payload, length); } - payload.extend_from_slice(&buffer[..buffer.len().min(remaining)]); } - self.start_write(cx, Bytes::from(payload), length) + Poll::Ready(Ok(0)) } fn is_write_vectored(&self) -> bool { @@ -182,16 +221,11 @@ impl AsyncWrite for FlowWriter { } if self.pending_action.is_none() { let shared = self.shared.clone(); - let flow_id = self.flow_id; self.pending_action = Some(Box::pin(async move { let (tx, rx) = oneshot::channel(); shared .data_tx - .send(Outbound { - header: frame_stream(flow_id, 0, 0)?, - payload: Bytes::new(), - flushed: Some(tx), - }) + .send(Outbound::Flush(tx)) .await .map_err(|_| closed())?; rx.await.map_err(|_| closed())? @@ -215,11 +249,7 @@ impl AsyncWrite for FlowWriter { self.pending_action = Some(Box::pin(async move { shared .data_tx - .send(Outbound { - header: frame_stream(flow_id, FLAG_FIN, 0)?, - payload: Bytes::new(), - flushed: None, - }) + .send(Outbound::Control(frame_close(flow_id, CLOSE_FIN)?)) .await .map_err(|_| closed()) })); @@ -227,7 +257,6 @@ impl AsyncWrite for FlowWriter { match self.poll_action(cx) { Poll::Ready(Ok(())) => { self.closed = true; - self.terminal_permit = None; Poll::Ready(Ok(())) } other => other, @@ -239,7 +268,7 @@ impl FlowWriter { fn start_write( &mut self, cx: &mut Context<'_>, - payload: Bytes, + payload: MuxChunk, length: usize, ) -> Poll> { let shared = self.shared.clone(); @@ -272,15 +301,41 @@ impl FlowWriter { } } +impl FlowWriter { + pub(crate) async fn send_chunk(&mut self, chunk: MuxChunk) -> io::Result<()> { + if self.closed + || self + .shared + .closed + .load(std::sync::atomic::Ordering::Acquire) + { + return Err(closed()); + } + if chunk.is_empty() { + return Ok(()); + } + if chunk.len() > FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "mux chunk exceeds frame size", + )); + } + super::driver::send_data(self.shared.clone(), self.flow_id, chunk).await + } +} + impl Drop for FlowWriter { fn drop(&mut self) { if !self.closed { - // One bounded dispatcher per carrier preserves ordering behind + // One dispatcher per carrier preserves ordering behind // already queued DATA without spawning a task for every dropped // stream. Dropping a writer is a half-close: split-direction users // intentionally discard the unused half while retaining the other. - if let Some(permit) = self.terminal_permit.take() { - permit.send(self.flow_id); + if self.shared.terminal_tx.try_send(self.flow_id).is_err() { + // Drop cannot wait for terminal delivery. A full queue means + // the peer is not draining control traffic, so fail the + // carrier before terminal metadata can grow without bound. + self.shared.close(); } } self.shared.release_part(self.flow_id); diff --git a/src/mux/wire.rs b/src/mux/wire.rs index 8030370..2e9c310 100644 --- a/src/mux/wire.rs +++ b/src/mux/wire.rs @@ -4,19 +4,22 @@ use std::error::Error; use std::fmt; +use crate::protocol::MAX_FLOW_ID; + pub(super) type FlowId = u32; -pub(super) const HEADER_LEN: usize = 8; +pub(super) const HEADER_LEN: usize = 7; -pub(super) const FLAG_SYN: u8 = 0x01; -pub(super) const FLAG_FIN: u8 = 0x02; -pub(super) const FLAG_RST: u8 = 0x04; +pub(super) const CLOSE_FIN: u8 = 0; +pub(super) const CLOSE_RESET: u8 = 1; #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(u8)] pub(super) enum FrameKind { - Stream = 0x01, - Window = 0x02, - Datagram = 0x03, + Open = 0x01, + Data = 0x02, + Window = 0x03, + Fin = 0x04, + Reset = 0x05, } impl TryFrom for FrameKind { @@ -24,9 +27,11 @@ impl TryFrom for FrameKind { fn try_from(value: u8) -> Result { match value { - 0x01 => Ok(Self::Stream), - 0x02 => Ok(Self::Window), - 0x03 => Ok(Self::Datagram), + 0x01 => Ok(Self::Open), + 0x02 => Ok(Self::Data), + 0x03 => Ok(Self::Window), + 0x04 => Ok(Self::Fin), + 0x05 => Ok(Self::Reset), _ => Err(WireError::UnknownKind(value)), } } @@ -35,25 +40,36 @@ impl TryFrom for FrameKind { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) struct FrameHeader { pub kind: FrameKind, - pub flags: u8, pub value: u16, pub flow_id: FlowId, } impl FrameHeader { - pub fn stream(flow_id: FlowId, flags: u8, payload_len: usize) -> Result { - Self::new(FrameKind::Stream, flags, payload_len, flow_id) + pub fn open(flow_id: FlowId, window_extension: usize) -> Result { + Self::new(FrameKind::Open, window_extension, flow_id) + } + + pub fn data(flow_id: FlowId, payload_len: usize) -> Result { + Self::new(FrameKind::Data, payload_len, flow_id) } pub fn window(flow_id: FlowId, credit: usize) -> Result { - Self::new(FrameKind::Window, 0, credit, flow_id) + Self::new(FrameKind::Window, credit, flow_id) } - fn new(kind: FrameKind, flags: u8, value: usize, flow_id: FlowId) -> Result { + pub fn close(flow_id: FlowId, code: u8) -> Result { + let kind = match code { + CLOSE_FIN => FrameKind::Fin, + CLOSE_RESET => FrameKind::Reset, + _ => return Err(WireError::InvalidClose), + }; + Self::new(kind, 0, flow_id) + } + + fn new(kind: FrameKind, value: usize, flow_id: FlowId) -> Result { let value = u16::try_from(value).map_err(|_| WireError::ValueTooLarge)?; let header = Self { kind, - flags, value, flow_id, }; @@ -63,31 +79,27 @@ impl FrameHeader { pub fn validate(self) -> Result<(), WireError> { match self.kind { - FrameKind::Stream => { - if self.flow_id == 0 { - return Err(WireError::InvalidFlowId); - } - if self.flags & !(FLAG_SYN | FLAG_FIN | FLAG_RST) != 0 { - return Err(WireError::ReservedFlags); - } - if self.flags & FLAG_RST != 0 && (self.flags != FLAG_RST || self.value != 0) { - return Err(WireError::InvalidReset); + FrameKind::Open => { + require_flow(self.flow_id)?; + } + FrameKind::Data => { + require_flow(self.flow_id)?; + if self.value == 0 { + return Err(WireError::InvalidData); } } FrameKind::Window => { - if self.flags != 0 { - return Err(WireError::ReservedFlags); - } if self.value == 0 { return Err(WireError::InvalidWindow); } - } - FrameKind::Datagram => { - if self.flow_id == 0 { - return Err(WireError::InvalidFlowId); + if self.flow_id != 0 { + require_flow(self.flow_id)?; } - if self.flags != 0 { - return Err(WireError::ReservedFlags); + } + FrameKind::Fin | FrameKind::Reset => { + require_flow(self.flow_id)?; + if self.value != 0 { + return Err(WireError::InvalidClose); } } } @@ -95,13 +107,20 @@ impl FrameHeader { } } +fn require_flow(flow_id: FlowId) -> Result<(), WireError> { + if flow_id == 0 || flow_id > MAX_FLOW_ID { + Err(WireError::InvalidFlowId) + } else { + Ok(()) + } +} + pub(super) fn encode_header(header: FrameHeader) -> Result<[u8; HEADER_LEN], WireError> { header.validate()?; let mut output = [0; HEADER_LEN]; output[0] = header.kind as u8; - output[1] = header.flags; - output[2..4].copy_from_slice(&header.value.to_be_bytes()); - output[4..8].copy_from_slice(&header.flow_id.to_be_bytes()); + output[1..3].copy_from_slice(&header.value.to_be_bytes()); + output[3..7].copy_from_slice(&header.flow_id.to_be_bytes()); Ok(output) } @@ -111,9 +130,8 @@ pub(super) fn decode_header(input: &[u8]) -> Result { } let header = FrameHeader { kind: FrameKind::try_from(input[0])?, - flags: input[1], - value: u16::from_be_bytes([input[2], input[3]]), - flow_id: u32::from_be_bytes(input[4..8].try_into().expect("fixed flow ID")), + value: u16::from_be_bytes([input[1], input[2]]), + flow_id: u32::from_be_bytes(input[3..7].try_into().expect("fixed flow ID")), }; header.validate()?; Ok(header) @@ -124,10 +142,10 @@ pub(super) enum WireError { InvalidHeaderLength(usize), UnknownKind(u8), ValueTooLarge, - ReservedFlags, InvalidFlowId, + InvalidData, InvalidWindow, - InvalidReset, + InvalidClose, } impl fmt::Display for WireError { @@ -138,12 +156,10 @@ impl fmt::Display for WireError { } Self::UnknownKind(kind) => write!(formatter, "unknown frame kind: {kind}"), Self::ValueTooLarge => formatter.write_str("frame value exceeds u16"), - Self::ReservedFlags => formatter.write_str("reserved frame flags are non-zero"), - Self::InvalidFlowId => formatter.write_str("invalid zero flow ID"), - Self::InvalidWindow => formatter.write_str("window credit must be non-zero"), - Self::InvalidReset => { - formatter.write_str("RST must be the only flag and carry no data") - } + Self::InvalidFlowId => formatter.write_str("flow ID is outside the 30-bit range"), + Self::InvalidData => formatter.write_str("DATA payload must be non-empty"), + Self::InvalidWindow => formatter.write_str("WINDOW credit must be non-zero"), + Self::InvalidClose => formatter.write_str("invalid CLOSE code or value"), } } } diff --git a/src/portal/config.rs b/src/portal/config.rs index 47e7384..ffd278e 100644 --- a/src/portal/config.rs +++ b/src/portal/config.rs @@ -8,10 +8,7 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; use tokio::sync::Semaphore; -use crate::common::{ - DEFAULT_MAX_TCP_FLOWS, DEFAULT_MAX_UDP_FLOWS, DEFAULT_TELEMETRY_INTERVAL, - MAX_TELEMETRY_INTERVAL, MIN_TELEMETRY_INTERVAL, -}; +use crate::common::{DEFAULT_TELEMETRY_INTERVAL, MAX_TELEMETRY_INTERVAL, MIN_TELEMETRY_INTERVAL}; use super::DEFAULT_QUIC_UDP_QUEUE_BYTES; @@ -25,13 +22,10 @@ const DEFAULT_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); const DEFAULT_REPORT_INTERVAL: Duration = Duration::from_secs(5); const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); const DEFAULT_RELOAD_INTERVAL: Duration = Duration::from_secs(60 * 60); -const DEFAULT_MAX_PENDING_PAIRS: usize = 1024; const DEFAULT_FLOW_PAIR_TIMEOUT: Duration = Duration::from_secs(15); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) struct PortalRuntimeConfig { - pub(super) max_tcp_flows: u32, - pub(super) max_udp_flows: usize, pub(super) udp_queue_bytes: usize, pub(super) tcp_data_buf_size: usize, pub(super) udp_data_buf_size: usize, @@ -44,15 +38,10 @@ pub(super) struct PortalRuntimeConfig { pub(super) telemetry_interval: Duration, pub(super) shutdown_timeout: Duration, pub(super) reload_interval: Duration, - pub(super) max_pending_pairs: usize, pub(super) flow_pair_timeout: Duration, } impl PortalRuntimeConfig { - pub(super) fn quic_bidi_stream_capacity(&self) -> u32 { - self.max_tcp_flows + self.max_udp_flows as u32 - } - pub(super) fn from_env() -> Result { Self::from_source(|name| match std::env::var(name) { Ok(value) => Ok(Some(value)), @@ -67,16 +56,6 @@ impl PortalRuntimeConfig { where F: FnMut(&str) -> Result>, { - let max_tcp_flows = read_u32(&mut source, "NOW_MAX_TCP_FLOWS", DEFAULT_MAX_TCP_FLOWS)?; - let max_udp_flows = read_usize( - &mut source, - "NOW_MAX_UDP_FLOWS", - DEFAULT_MAX_UDP_FLOWS, - (u32::MAX as usize).min(Semaphore::MAX_PERMITS), - )?; - max_tcp_flows - .checked_add(max_udp_flows as u32) - .context("portal::config: NOW_MAX_TCP_FLOWS + NOW_MAX_UDP_FLOWS exceeds u32")?; let udp_queue_bytes = read_usize( &mut source, "NOW_QUIC_UDP_QUEUE_BYTES", @@ -143,12 +122,6 @@ impl PortalRuntimeConfig { )?; let reload_interval = read_duration(&mut source, "NOW_RELOAD_INTERVAL", DEFAULT_RELOAD_INTERVAL)?; - let max_pending_pairs = read_usize( - &mut source, - "NOW_MAX_PENDING_PAIRS", - DEFAULT_MAX_PENDING_PAIRS, - isize::MAX as usize, - )?; let flow_pair_timeout = read_duration( &mut source, "NOW_FLOW_PAIR_TIMEOUT", @@ -156,8 +129,6 @@ impl PortalRuntimeConfig { )?; Ok(Self { - max_tcp_flows, - max_udp_flows, udp_queue_bytes, tcp_data_buf_size, udp_data_buf_size, @@ -170,28 +141,11 @@ impl PortalRuntimeConfig { telemetry_interval, shutdown_timeout, reload_interval, - max_pending_pairs, flow_pair_timeout, }) } } -fn read_u32(source: &mut F, name: &str, default: u32) -> Result -where - F: FnMut(&str) -> Result>, -{ - let Some(raw) = source(name)? else { - return Ok(default); - }; - let value = raw - .parse::() - .with_context(|| format!("portal::config: invalid {name}={raw:?}"))?; - if value == 0 { - bail!("portal::config: {name} must be greater than zero: {raw:?}"); - } - Ok(value) -} - fn read_usize(source: &mut F, name: &str, default: usize, max: usize) -> Result where F: FnMut(&str) -> Result>, diff --git a/src/portal/conn.rs b/src/portal/conn.rs index 4ea08d7..a3f8428 100644 --- a/src/portal/conn.rs +++ b/src/portal/conn.rs @@ -13,6 +13,7 @@ pub(in crate::portal) use self::session::QueuedDatagram; use std::sync::Arc; +use quinn::crypto::rustls::HandshakeData; use quinn::{Connection, Incoming, VarInt}; use tokio::time::timeout; use tokio_util::sync::CancellationToken; @@ -25,6 +26,7 @@ pub(super) use self::tcp::handle_tcp_incoming; use super::PortalInner; use super::admission::UnauthenticatedGuard; use crate::common::rate_limit_bytes_per_second; +use crate::protocol::ALPN; use crate::telemetry::{RuntimeEvent, RuntimeKind, RuntimeLevel}; pub(super) async fn handle_incoming( @@ -55,6 +57,17 @@ pub(super) async fn handle_incoming( // Keep them silent to avoid log amplification. Err(_) => return, }; + let valid_alpn = conn + .handshake_data() + .and_then(|data| data.downcast::().ok()) + .is_some_and(|data| data.protocol.as_deref() == Some(ALPN)); + if !valid_alpn { + conn.close(VarInt::from_u32(1), b"unsupported protocol"); + portal.logger.debug(format_args!( + "portal::conn::handle_incoming: invalid negotiated QUIC protocol" + )); + return; + } handle_connection(portal, conn, admission, shutdown).await; } @@ -96,7 +109,7 @@ async fn handle_connection( } // Once auth succeeds, expand the conservative pre-auth limits to the normal // data-plane limits and release the admission slot. - let flow_control = match crate::transport::quic_flow_control() { + let flow_control = match crate::transport::transport_flow_control() { Ok(value) => value, Err(err) => { portal.logger.error(format_args!( @@ -108,16 +121,18 @@ async fn handle_connection( } }; conn.set_receive_window(VarInt::from_u32(flow_control.connection_receive_window)); - conn.set_max_concurrent_bi_streams(VarInt::from_u32( - portal.runtime.quic_bidi_stream_capacity(), - )); + conn.set_max_concurrent_bi_streams( + portal + .pairing + .quic_stream_credit(authenticated.session.session_key), + ); drop(admission); let session = authenticated.session; let link_replaced = CancellationToken::new(); let link_guard = portal .pairing .register_quic_link( - session.session_id, + session.session_key, portal.stats.clone(), link_replaced.clone(), ) diff --git a/src/portal/conn/relay_stream.rs b/src/portal/conn/relay_stream.rs index 9af318e..e3de4e8 100644 --- a/src/portal/conn/relay_stream.rs +++ b/src/portal/conn/relay_stream.rs @@ -6,40 +6,38 @@ use std::sync::Arc; use std::sync::atomic::Ordering; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tokio::time::timeout; use crate::portal::PortalInner; use crate::protocol::Carrier; use crate::telemetry::AccessSpan; -use crate::transport::BufferLease; +use crate::transport::{ + AsyncReadAny, AsyncWriteAny, read_owned, read_owned_from, write_owned, write_owned_to, +}; /// Relays both directions until one side closes or either direction errors. -pub(in crate::portal) async fn relay_stream( +pub(in crate::portal) async fn relay_stream( portal: Arc, - client_read: &mut R, - client_write: &mut W, + client_read: &mut std::pin::Pin>, + client_write: &mut std::pin::Pin>, target: (TR, TW), - buffers: (BufferLease, BufferLease), carriers: Option<(Carrier, Carrier)>, access: &AccessSpan, ) -> anyhow::Result<()> where - R: AsyncRead + Unpin, - W: AsyncWrite + Unpin, TR: AsyncRead + Unpin, TW: AsyncWrite + Unpin, { - let (mut buffer1, mut buffer2) = buffers; let (mut target_read, mut target_write) = target; let client_to_target = async { loop { - let n = client_read.read(&mut buffer1).await?; - if n == 0 { + let Some(chunk) = read_owned(client_read, &portal.buffers).await? else { target_write.shutdown().await?; return Ok::<(), anyhow::Error>(()); - } + }; + let n = chunk.len(); access.add_upload(n as u64); portal.stats.tcp_rx.fetch_add(n as u64, Ordering::Relaxed); if let Some((uplink, _)) = carriers { @@ -52,21 +50,21 @@ where if let Some(limiter) = &portal.rate_limiter { limiter.wait_read(n as i64).await; } - target_write.write_all(&buffer1[..n]).await?; + write_owned_to(&mut target_write, chunk).await?; } }; let target_to_client = async { loop { - let n = target_read.read(&mut buffer2).await?; - if n == 0 { + let Some(chunk) = read_owned_from(&mut target_read, &portal.buffers).await? else { client_write.shutdown().await?; return Ok::<(), anyhow::Error>(()); - } + }; + let n = chunk.len(); if let Some(limiter) = &portal.rate_limiter { limiter.wait_write(n as i64).await; } - client_write.write_all(&buffer2[..n]).await?; + write_owned(client_write, chunk).await?; if carriers.is_some_and(|(uplink, downlink)| { uplink == Carrier::TlsTcp && downlink == Carrier::Quic }) { diff --git a/src/portal/conn/relay_tcp.rs b/src/portal/conn/relay_tcp.rs index 5f43deb..ee212b2 100644 --- a/src/portal/conn/relay_tcp.rs +++ b/src/portal/conn/relay_tcp.rs @@ -41,7 +41,6 @@ pub(in crate::portal) async fn relay_paired_tcp(portal: Arc, paired id: 0, timestamp_ms: now_unix_ms(), protocol: TrafficProtocol::Tcp, - alpn: portal.alpn.clone(), flow_id: Some(flow_id.into()), session_tag: None, client: Some(uplink_path.peer.clone()), @@ -154,10 +153,6 @@ pub(in crate::portal) async fn relay_paired_tcp(portal: Arc, paired &mut client_read, &mut client_write, (target_read, target_write), - ( - portal.buffers.get_tcp_buffer(), - portal.buffers.get_tcp_buffer(), - ), Some((uplink, downlink)), &access, ); diff --git a/src/portal/conn/relay_uot.rs b/src/portal/conn/relay_uot.rs index a7f85e4..9b8c87e 100644 --- a/src/portal/conn/relay_uot.rs +++ b/src/portal/conn/relay_uot.rs @@ -48,7 +48,6 @@ pub(in crate::portal) async fn relay_paired_udp(portal: Arc, paired id: 0, timestamp_ms: now_unix_ms(), protocol: TrafficProtocol::Udp, - alpn: portal.alpn.clone(), flow_id: Some(flow_id.into()), session_tag: None, client: Some(uplink_path.peer.clone()), diff --git a/src/portal/conn/session.rs b/src/portal/conn/session.rs index 104c51d..cc09834 100644 --- a/src/portal/conn/session.rs +++ b/src/portal/conn/session.rs @@ -19,11 +19,12 @@ use tokio::time::timeout; use crate::protocol::{ Carrier, DatagramReassembler, FlowErrorCode, FlowKind, FlowResult, FlowRole, ReassemblyConfig, - SessionId, read_flow_header, read_request, write_flow_result, + read_flow_header, read_request, write_flow_result, }; pub(in crate::portal) use self::flow::QueuedDatagram; use crate::portal::PortalInner; +use crate::portal::pairing::SessionKey; const FLOW_REJECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); const UDP_REASSEMBLY_SLOTS: usize = 64; @@ -33,7 +34,7 @@ const UDP_REASSEMBLY_TTL: std::time::Duration = std::time::Duration::from_secs(1 pub(super) struct PortalSession { portal: Arc, conn: Connection, - pub(super) session_id: SessionId, + pub(super) session_key: SessionKey, quic_generation: AtomicU64, udp_flows: StdMutex>, udp_reassembler: StdMutex>, @@ -59,13 +60,18 @@ impl PortalSession { peer: self.conn.remote_address().to_string(), local: self.conn.local_ip().map_or_else( || self.portal.endpoint_addr.clone(), - |ip| std::net::SocketAddr::new(ip, self.portal.listen_port).to_string(), + |ip| { + self.portal.udp_listen_port.map_or_else( + || self.portal.endpoint_addr.clone(), + |port| std::net::SocketAddr::new(ip, port).to_string(), + ) + }, ), } } /// Creates session state for one authenticated QUIC connection. - pub(super) fn new(portal: Arc, conn: Connection, session_id: SessionId) -> Self { + pub(super) fn new(portal: Arc, conn: Connection, session_key: SessionKey) -> Self { let (udp_ready_tx, udp_ready_rx) = mpsc::channel(64); let udp_reassembly_config = ReassemblyConfig { max_slots: UDP_REASSEMBLY_SLOTS, @@ -76,7 +82,7 @@ impl PortalSession { udp_queue_budget: Arc::new(Semaphore::new(portal.udp_flow_limits.queue_bytes)), portal, conn, - session_id, + session_key, quic_generation: AtomicU64::new(0), udp_flows: StdMutex::new(HashMap::new()), udp_reassembler: StdMutex::new(DatagramReassembler::new(udp_reassembly_config)), @@ -155,7 +161,7 @@ impl PortalSession { self.portal .pairing .reject_flow_setup( - self.session_id, + self.session_key, header.flow_id, FlowErrorCode::InvalidRequest, ) @@ -176,7 +182,7 @@ impl PortalSession { self.portal .pairing .reject_flow_setup( - self.session_id, + self.session_key, header.flow_id, FlowErrorCode::FlowLimit, ) @@ -193,7 +199,7 @@ impl PortalSession { self.portal .pairing .reject_flow_setup( - self.session_id, + self.session_key, header.flow_id, FlowErrorCode::InvalidRequest, ) @@ -216,12 +222,15 @@ impl PortalSession { FlowRole::Attach => (None, Some(Box::pin(send) as _)), FlowRole::Duplex => (Some(Box::pin(recv) as _), Some(Box::pin(send) as _)), }; - match self + let paired = self .portal .pairing - .submit_tcp(self.session_id, header, target, link, reader, writer, None) - .await - { + .submit_tcp(self.session_key, header, target, link, reader, writer, None) + .await; + self.conn.set_max_concurrent_bi_streams( + self.portal.pairing.quic_stream_credit(self.session_key), + ); + match paired { Ok(Some(paired)) => { let relay = super::relay::relay_paired_tcp(self.portal.clone(), paired); if let Some(relay) = self.portal.relay_tasks.spawn_or_return(relay) { @@ -241,7 +250,7 @@ impl PortalSession { self.portal .pairing .reject_flow_setup( - self.session_id, + self.session_key, header.flow_id, FlowErrorCode::MetadataConflict, ) @@ -272,12 +281,15 @@ impl PortalSession { } } }; - match self + let paired = self .portal .pairing - .submit_udp(self.session_id, header, target, link, half) - .await - { + .submit_udp(self.session_key, header, target, link, half) + .await; + self.conn.set_max_concurrent_bi_streams( + self.portal.pairing.quic_stream_credit(self.session_key), + ); + match paired { Ok(Some(paired)) => { let relay = super::relay::relay_paired_udp(self.portal.clone(), paired); if let Some(relay) = self.portal.relay_tasks.spawn_or_return(relay) { diff --git a/src/portal/conn/session_datagram.rs b/src/portal/conn/session_datagram.rs index bea9e71..383f588 100644 --- a/src/portal/conn/session_datagram.rs +++ b/src/portal/conn/session_datagram.rs @@ -182,7 +182,7 @@ impl PortalSession { self.remove_udp_uplink(flow_id); self.portal .pairing - .cancel_udp(self.session_id, flow_id) + .cancel_udp(self.session_key, flow_id) .await; } diff --git a/src/portal/conn/tcp/flow.rs b/src/portal/conn/tcp/flow.rs index 9a319fd..b91f5fc 100644 --- a/src/portal/conn/tcp/flow.rs +++ b/src/portal/conn/tcp/flow.rs @@ -12,10 +12,10 @@ use tokio::time::timeout; use tokio_util::sync::CancellationToken; use crate::portal::PortalInner; -use crate::portal::pairing::{BoxReader, BoxWriter, LinkGuard}; +use crate::portal::pairing::{BoxReader, BoxWriter, LinkGuard, SessionKey}; use crate::protocol::{ - Carrier, FlowErrorCode, FlowKind, FlowResult, FlowRole, SessionId, read_flow_header, - read_request, write_flow_result, + Carrier, FlowErrorCode, FlowKind, FlowResult, FlowRole, read_flow_header, read_request, + write_flow_result, }; const FLOW_REJECT_TIMEOUT: Duration = Duration::from_secs(1); @@ -25,7 +25,7 @@ pub(super) async fn process_flow( portal: Arc, recv: R, mut send: W, - session_id: SessionId, + session_id: SessionKey, peer: SocketAddr, local: Option, shutdown: CancellationToken, @@ -36,7 +36,9 @@ pub(super) async fn process_flow( R: AsyncRead + Send + Unpin + 'static, W: AsyncWrite + Send + Unpin + 'static, { - let mut recv = BufReader::new(recv); + // A one-byte staging buffer keeps exact handshake reads from prefetching + // application payload out of an owned Mux chunk. + let mut recv = BufReader::with_capacity(1, recv); let header = match tokio::select! { result = timeout(flow_timeout, read_flow_header(&mut recv)) => Some(result), _ = shutdown.cancelled() => None, @@ -180,7 +182,7 @@ where async fn reject_invalid( portal: &Arc, - session_id: SessionId, + session_id: SessionKey, role: FlowRole, flow_id: u32, writer: &mut W, diff --git a/src/portal/conn/tcp/mod.rs b/src/portal/conn/tcp/mod.rs index af19f07..05d5b73 100644 --- a/src/portal/conn/tcp/mod.rs +++ b/src/portal/conn/tcp/mod.rs @@ -16,15 +16,17 @@ use tokio::io::AsyncReadExt; use tokio::net::TcpStream; use tokio::task::JoinSet; use tokio::time::{timeout, timeout_at}; -use tokio_rustls::TlsAcceptor; +use tokio_rustls::LazyConfigAcceptor; use tokio_util::sync::CancellationToken; use crate::common::MUX_MARKER; use crate::mux::{MUX_IDLE_TIMEOUT, MuxConfig, MuxHandle}; use crate::portal::PortalInner; use crate::portal::admission::UnauthenticatedGuard; -use crate::protocol::{AuthTransport, SessionId, read_auth_frame}; +use crate::portal::pairing::SessionKey; +use crate::protocol::{ALPN, AuthTransport, read_auth_frame}; use crate::telemetry::{RuntimeEvent, RuntimeKind, RuntimeLevel}; +use crate::transport::MorphTcpStream; use self::flow::process_flow; use super::auth::{authentication_deadline, wait_for_auth_deadline}; @@ -89,12 +91,27 @@ pub(super) async fn handle_tcp_incoming_with_timeouts( .debug(format_args!("portal::conn::tcp: TCP_NODELAY failed: {err}")); } let local = stream.local_addr().ok(); - let acceptor = TlsAcceptor::from(portal.tls_server_config.clone()); + let server_config = portal.tls_server_config.clone(); + let morph_keys = portal.morph_keys.clone(); let tls_stream = match tokio::select! { biased; _ = shutdown.cancelled() => return, _ = portal.drain.cancelled() => return, - result = timeout(portal.runtime.handshake_timeout, acceptor.accept(stream)) => result, + result = timeout(portal.runtime.handshake_timeout, async move { + let stream = MorphTcpStream::server(stream, morph_keys); + let start = LazyConfigAcceptor::new(rustls::server::Acceptor::default(), stream).await?; + let offers_nw2 = start + .client_hello() + .alpn() + .is_some_and(|mut protocols| protocols.any(|protocol| protocol == ALPN)); + if !offers_nw2 { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + "client did not offer nw2 ALPN", + )); + } + start.into_stream(server_config).await + }) => result, } { Ok(Ok(stream)) => stream, Ok(Err(err)) => { @@ -113,14 +130,8 @@ pub(super) async fn handle_tcp_incoming_with_timeouts( } Err(_) => return, }; - let auth_deadline = authentication_deadline(portal.runtime.handshake_timeout); let mut tls_stream = tls_stream; - if tls_stream.get_ref().1.alpn_protocol() != Some(portal.alpn.as_bytes()) { - portal.logger.debug(format_args!( - "portal::conn::tcp: peer did not negotiate the configured ALPN" - )); - return; - } + let auth_deadline = authentication_deadline(portal.runtime.handshake_timeout); let mut exporter = [0u8; 32]; if let Err(err) = tls_stream.get_ref().1.export_keying_material( &mut exporter, @@ -165,7 +176,8 @@ pub(super) async fn handle_tcp_incoming_with_timeouts( } Err(_) => return, }; - if let Err(err) = SockRef::from(tls_stream.get_ref().0).set_keepalive(true) { + let session_key = session_id; + if let Err(err) = SockRef::from(tls_stream.get_ref().0.get_ref()).set_keepalive(true) { portal.logger.debug(format_args!( "portal::conn::tcp: TCP keepalive failed: {err}" )); @@ -185,7 +197,7 @@ pub(super) async fn handle_tcp_incoming_with_timeouts( handle_mux( portal, tls_stream, - session_id, + session_key, peer, local, shutdown, @@ -197,13 +209,13 @@ pub(super) async fn handle_tcp_incoming_with_timeouts( let link_guard = portal .pairing - .register_tcp_link(session_id, portal.stats.clone()); + .register_tcp_link(session_key, portal.stats.clone()); let (recv, send) = tokio::io::split(tls_stream); process_flow( portal, Cursor::new([first]).chain(recv), send, - session_id, + session_key, peer, local, shutdown, @@ -216,8 +228,8 @@ pub(super) async fn handle_tcp_incoming_with_timeouts( async fn handle_mux( portal: Arc, - tls_stream: tokio_rustls::server::TlsStream, - session_id: SessionId, + tls_stream: tokio_rustls::server::TlsStream>, + session_key: SessionKey, peer: SocketAddr, local: Option, shutdown: CancellationToken, @@ -235,7 +247,7 @@ async fn handle_mux( }; let _link_guard = portal .pairing - .register_tcp_link(session_id, portal.stats.clone()); + .register_tcp_link(session_key, portal.stats.clone()); portal.telemetry.emit_runtime( RuntimeEvent::new( RuntimeLevel::Info, @@ -268,7 +280,7 @@ async fn handle_mux( portal, recv, send, - session_id, + session_key, peer, local, shutdown, diff --git a/src/portal/listener.rs b/src/portal/listener.rs index 06eb3d6..9501695 100644 --- a/src/portal/listener.rs +++ b/src/portal/listener.rs @@ -3,18 +3,21 @@ //! QUIC endpoint and TCP listener setup plus accept loops. -use std::net::{IpAddr, SocketAddr}; +use std::net::SocketAddr; use std::sync::Arc; use anyhow::{Context, Result}; -use quinn::{Endpoint, EndpointConfig, IdleTimeout, ServerConfig, VarInt, default_runtime}; +use quinn::{Endpoint, IdleTimeout, ServerConfig, VarInt, default_runtime}; use socket2::{Domain, Protocol, Socket, Type}; use tokio::net::TcpListener; use tokio::time::{Duration, sleep}; use tokio_util::sync::CancellationToken; use crate::telemetry::{RuntimeEvent, RuntimeKind, RuntimeLevel}; -use crate::transport::quic_flow_control; +use crate::transport::{ + MorphKeys, configure_morph_mtu, morph_endpoint_config, transport_flow_control, + wrap_morph_udp_socket, +}; use super::{PortalInner, conn}; @@ -132,13 +135,22 @@ pub(super) async fn accept_tcp_loop( } /// Opens a Quinn endpoint on an already configured server config. -pub(super) fn listen_endpoint(server_config: ServerConfig, addr: SocketAddr) -> Result { +pub(super) fn listen_endpoint( + server_config: ServerConfig, + addr: SocketAddr, + morph_keys: Option, +) -> Result { let socket = bind_quic_socket(addr) .with_context(|| format!("portal::listen_endpoint: failed to bind UDP socket: {addr}"))?; let runtime = default_runtime() .ok_or_else(|| anyhow::anyhow!("portal::listen_endpoint: no async runtime found"))?; - Endpoint::new( - EndpointConfig::default(), + let socket = runtime + .wrap_udp_socket(socket) + .context("portal::listen_endpoint: failed to initialize UDP runtime socket")?; + let morph_enabled = morph_keys.is_some(); + let socket = wrap_morph_udp_socket(socket, morph_keys.map(|keys| keys.udp_key()))?; + Endpoint::new_with_abstract_socket( + morph_endpoint_config(morph_enabled)?, Some(server_config), socket, runtime, @@ -178,22 +190,14 @@ pub(super) fn listen_tcp(addr: SocketAddr) -> Result { .with_context(|| format!("portal::listen_tcp: failed to listen for TLS/TCP on {addr}")) } -/// Formats a visible endpoint address without adding brackets to empty hosts. -pub(super) fn format_endpoint_addr(host: &str, port: u16) -> String { - match host.parse::() { - Ok(ip) => SocketAddr::new(ip, port).to_string(), - Err(_) if host.is_empty() => format!(":{port}"), - Err(_) => format!("{host}:{port}"), - } -} - /// Applies transport limits that should be set before the config is shared. pub(super) fn configure_transport( server_config: &mut quinn::ServerConfig, udp_idle_timeout: Duration, keep_alive_interval: Option, + morph_enabled: bool, ) -> Result<()> { - let flow_control = quic_flow_control()?; + let flow_control = transport_flow_control()?; let transport = Arc::get_mut(&mut server_config.transport).ok_or_else(|| { anyhow::anyhow!("portal::configure_transport: server transport already shared") })?; @@ -207,6 +211,11 @@ pub(super) fn configure_transport( transport.max_idle_timeout(Some(IdleTimeout::try_from(udp_idle_timeout)?)); transport.keep_alive_interval(keep_alive_interval); transport.congestion_controller_factory(Arc::new(quinn::congestion::BbrConfig::default())); + configure_morph_mtu(transport, morph_enabled); Ok(()) } + +#[cfg(test)] +#[path = "../tests/portal/listener.rs"] +mod socket_tests; diff --git a/src/portal/mod.rs b/src/portal/mod.rs index a351e1c..4e8ffbf 100644 --- a/src/portal/mod.rs +++ b/src/portal/mod.rs @@ -22,7 +22,7 @@ use tokio_util::sync::CancellationToken; use crate::common::{Lifecycle, Logger, TLSMode}; use crate::protocol::Credentials; use crate::telemetry::TelemetryHub; -use crate::transport::{Buffers, RateLimiter, Stats}; +use crate::transport::{Buffers, MorphKeys, RateLimiter, Stats}; use self::config::PortalRuntimeConfig; pub(crate) use self::mode::NetworkMode; @@ -32,7 +32,6 @@ const DEFAULT_QUIC_UDP_QUEUE_BYTES: usize = 4 * 1024 * 1024; #[derive(Clone, Copy, Debug)] struct UdpFlowLimits { - max_flows: usize, queue_bytes: usize, } @@ -44,12 +43,15 @@ pub struct Portal { struct PortalInner { credentials: Credentials, - alpn: String, + morph_keys: Option, tls_mode: TLSMode, network_mode: NetworkMode, endpoint_addr: String, - bind_addrs: Vec, - listen_port: u16, + tcp_bind_addrs: Vec, + udp_bind_addrs: Vec, + allow_tcp_family_degrade: bool, + allow_udp_family_degrade: bool, + udp_listen_port: Option, outbound: PortalOutbound, rate_limit: i32, etar_limit: i32, diff --git a/src/portal/mode.rs b/src/portal/mode.rs index 31c44b8..edea887 100644 --- a/src/portal/mode.rs +++ b/src/portal/mode.rs @@ -5,10 +5,7 @@ use std::fmt; -use anyhow::Result; -use url::Url; - -/// Portal listener mode selected by the `net` URL query parameter. +/// Portal listener mode derived from declared carrier endpoints. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum NetworkMode { Mix, @@ -17,19 +14,12 @@ pub(crate) enum NetworkMode { } impl NetworkMode { - /// Parses the URL `net` query parameter, defaulting to mixed service. - pub(super) fn from_url(parsed_url: &Url) -> Result { - match parsed_url - .query_pairs() - .find(|(key, _)| key == "net") - .map(|(_, value)| value) - .as_deref() - { - None | Some("") => Ok(Self::Mix), - Some("mix") => Ok(Self::Mix), - Some("tcp") => Ok(Self::Tcp), - Some("udp") => Ok(Self::Udp), - Some(_) => Err(anyhow::anyhow!("portal::NetworkMode: invalid net mode")), + pub(super) fn from_carriers(tcp: bool, udp: bool) -> Self { + match (tcp, udp) { + (true, true) => Self::Mix, + (true, false) => Self::Tcp, + (false, true) => Self::Udp, + (false, false) => unreachable!("endpoint parser requires at least one carrier"), } } diff --git a/src/portal/pairing.rs b/src/portal/pairing.rs index 01e7519..f650ccc 100644 --- a/src/portal/pairing.rs +++ b/src/portal/pairing.rs @@ -7,15 +7,14 @@ use std::collections::{HashMap, HashSet}; use std::fmt; use std::sync::Arc; use std::sync::Mutex as StdMutex; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use tokio::io::AsyncWriteExt; -use tokio::sync::{Mutex, OwnedSemaphorePermit}; +use tokio::sync::{Mutex, Semaphore}; use crate::protocol::{ - Carrier, FlowErrorCode, FlowHeader, FlowKind, FlowResult, FlowRole, SessionId, Target, - write_flow_result, + Carrier, FlowErrorCode, FlowHeader, FlowKind, FlowResult, FlowRole, Target, write_flow_result, }; mod lifecycle; @@ -28,13 +27,15 @@ pub(in crate::portal) use self::link::LinkGuard; pub(super) use self::link::{guarded_reader, guarded_writer}; pub(super) use self::state::{ BoxReader, BoxWriter, FlowLease, LinkHalf, LinkPath, PairedTcp, PairedUdp, QuicUdpReceiver, - UdpDown, UdpHalf, UdpUp, + SessionKey, UdpDown, UdpHalf, UdpUp, }; use self::state::{FlowClaim, FlowKey, LinkCounts, Metadata, PendingTcp, PendingUdp}; use self::tcp::reject_tcp_writer; use self::udp::reject_udp_downlink_ref; const FLOW_RESULT_WRITE_TIMEOUT: Duration = Duration::from_secs(1); +const SESSION_FLOW_RESOURCE_LIMIT: usize = 4096; +const PORTAL_FLOW_RESOURCE_LIMIT: usize = 65_536; #[derive(Clone, Copy)] struct TerminalRejection { @@ -69,42 +70,36 @@ impl std::error::Error for PairingError {} pub(super) struct PairingRegistry { pub(super) tcp: Mutex>, pub(super) udp: Mutex>, - pub(super) links: StdMutex>, + pub(super) links: StdMutex>, claims: StdMutex>, + claim_admission: Arc, rejections: StdMutex>, accepting: AtomicBool, pub(super) next_quic_generation: AtomicU64, next_epoch: AtomicU64, - pub(super) max_pending: usize, pub(super) timeout: Duration, - pub(super) max_tcp_flows: usize, - pub(super) max_udp_flows: usize, } +// Bounded failure-history cache, independent of live/pending flow admission. +const MAX_REJECTION_TOMBSTONES: usize = 1024; + impl PairingRegistry { - pub(super) fn new( - max_tcp_flows: usize, - max_udp_flows: usize, - max_pending: usize, - timeout: Duration, - ) -> Self { + pub(super) fn new(timeout: Duration) -> Self { Self { tcp: Mutex::new(HashMap::new()), udp: Mutex::new(HashMap::new()), links: StdMutex::new(HashMap::new()), claims: StdMutex::new(HashMap::new()), + claim_admission: Arc::new(Semaphore::new(PORTAL_FLOW_RESOURCE_LIMIT)), rejections: StdMutex::new(HashMap::new()), accepting: AtomicBool::new(true), next_quic_generation: AtomicU64::new(1), next_epoch: AtomicU64::new(1), - max_pending, timeout, - max_tcp_flows, - max_udp_flows, } } - fn active_quic_generation(&self, session_id: SessionId) -> Option { + fn active_quic_generation(&self, session_id: SessionKey) -> Option { self.links .lock() .expect("link registry poisoned") @@ -114,9 +109,9 @@ impl PairingRegistry { fn validate_current_link_locked( &self, - session_id: SessionId, + session_id: SessionKey, link: &LinkHalf, - links: &HashMap, + links: &HashMap, ) -> Result<(), PairingError> { let current = links.get(&session_id); let valid = match link.quic_generation { @@ -137,7 +132,7 @@ impl PairingRegistry { fn validate_header_and_link( &self, - session_id: SessionId, + session_id: SessionKey, header: FlowHeader, expected_kind: FlowKind, target: Option<&Target>, @@ -189,6 +184,8 @@ impl PairingRegistry { metadata: Metadata, target: Option, quic_generation: Option, + quic_count: Option>, + session_admission: Option>, ) -> Result<(u64, bool), PairingError> { let mut claims = self.claims.lock().expect("flow claim registry poisoned"); // The claims lock is the drain/admission linearization point. Once @@ -225,35 +222,40 @@ impl PairingRegistry { } return Ok((claim.epoch, false)); } - if metadata.kind == FlowKind::Tcp - && claims - .iter() - .filter(|(flow, claim)| { - flow.session_id == key.session_id && claim.metadata.kind == FlowKind::Tcp - }) - .count() - >= self.max_tcp_flows - { - return Err(PairingError::new( - FlowErrorCode::FlowLimit, - "portal::pairing: TCP flow limit reached", - )); - } - if claims - .iter() - .filter(|(flow, claim)| flow.session_id == key.session_id && !claim.active) - .count() - >= self.max_pending - { - return Err(PairingError::new( + let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed); + let session_admission = session_admission.ok_or_else(|| { + PairingError::new( + FlowErrorCode::SessionReplaced, + "portal::pairing: session disappeared before flow admission", + ) + })?; + let session_admission = session_admission.try_acquire_owned().map_err(|_| { + PairingError::new( FlowErrorCode::FlowLimit, - "portal::pairing: pending flow limit reached", - )); + "portal::pairing: session flow resource limit reached", + ) + })?; + let portal_admission = self + .claim_admission + .clone() + .try_acquire_owned() + .map_err(|_| { + PairingError::new( + FlowErrorCode::FlowLimit, + "portal::pairing: Portal flow resource limit reached", + ) + })?; + let quic_count = quic_count + .filter(|_| metadata.uplink == Carrier::Quic || metadata.downlink == Carrier::Quic); + if let Some(count) = &quic_count { + count.fetch_add(1, Ordering::Relaxed); } - let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed); claims.insert( key, FlowClaim { + _portal_admission: portal_admission, + _session_admission: session_admission, + quic_count, epoch, metadata, target, @@ -265,6 +267,36 @@ impl PairingRegistry { Ok((epoch, true)) } + fn quic_flow_counter(&self, session_id: SessionKey) -> Option> { + self.links + .lock() + .expect("link registry poisoned") + .get(&session_id) + .map(|counts| counts.quic_flows.clone()) + } + + fn session_flow_admission(&self, session_id: SessionKey) -> Option> { + self.links + .lock() + .expect("link registry poisoned") + .get(&session_id) + .map(|counts| counts.flow_admission.clone()) + } + + pub(super) fn quic_stream_credit(&self, session_id: SessionKey) -> quinn::VarInt { + // Quinn preallocates stream state. Keep a sliding headroom for setup, + // instead of advertising a huge fixed count or capping active flows. + let live = self + .quic_flow_counter(session_id) + .map_or(0, |count| count.load(Ordering::Relaxed)); + // Quinn batches MAX_STREAMS updates at 1/8 of its window. Headroom + // must grow too, otherwise a fixed reserve eventually stalls updates. + quinn::VarInt::from_u32( + live.saturating_add((live / 4).max(64)) + .min(SESSION_FLOW_RESOURCE_LIMIT) as u32, + ) + } + fn refresh_claim(&self, key: FlowKey) -> Result { let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed); let mut claims = self.claims.lock().expect("flow claim registry poisoned"); @@ -293,7 +325,6 @@ impl PairingRegistry { key: FlowKey, epoch: u64, quic_generations: Vec, - udp_permit: Option>, ) -> Result { // `links -> claims` is the linearization barrier shared with QUIC // replacement. A generation cannot become active after it has been @@ -340,31 +371,6 @@ impl PairingRegistry { key, epoch, cancel, - _udp_permit: udp_permit, - }) - } - - fn acquire_udp_permit( - &self, - session_id: SessionId, - ) -> Result, PairingError> { - let budget = self - .links - .lock() - .expect("link registry poisoned") - .get(&session_id) - .map(|counts| counts.udp_flow_budget.clone()) - .ok_or_else(|| { - PairingError::new( - FlowErrorCode::SessionReplaced, - "portal::pairing: missing authenticated session", - ) - })?; - budget.try_acquire_owned().map(Arc::new).map_err(|_| { - PairingError::new( - FlowErrorCode::FlowLimit, - "portal::pairing: UDP flow limit reached", - ) }) } @@ -385,12 +391,13 @@ impl PairingRegistry { /// Terminates a setup attempt and delivers the exact failure to an already /// selected downlink. If OPEN failed before ATTACH arrived, retain a short /// tombstone so the later selected downlink receives the same result. - pub(super) async fn reject_flow_setup( + pub(super) async fn reject_flow_setup>( self: &Arc, - session_id: SessionId, + session_id: S, flow_id: u32, code: FlowErrorCode, ) { + let session_id = session_id.into(); let key = FlowKey { session_id, flow_id, @@ -414,7 +421,7 @@ impl PairingRegistry { let now = Instant::now(); rejections.retain(|_, rejection| rejection.expires_at > now); if !rejections.contains_key(&key) - && rejections.len() >= self.max_pending + && rejections.len() >= MAX_REJECTION_TOMBSTONES && let Some(oldest) = rejections .iter() .min_by_key(|(_, rejection)| rejection.expires_at) diff --git a/src/portal/pairing/lifecycle.rs b/src/portal/pairing/lifecycle.rs index 682ffb4..33f8d75 100644 --- a/src/portal/pairing/lifecycle.rs +++ b/src/portal/pairing/lifecycle.rs @@ -18,7 +18,7 @@ impl PairingRegistry { pub(in crate::portal) fn cancel_quic_generation( self: &Arc, - session_id: SessionId, + session_id: SessionKey, generation: u64, ) { self.cancel_active_quic_generation(session_id, generation); @@ -32,14 +32,14 @@ impl PairingRegistry { pub(in crate::portal) async fn replace_quic_generation( self: &Arc, - session_id: SessionId, + session_id: SessionKey, generation: u64, ) { self.cancel_active_quic_generation(session_id, generation); self.purge_quic_generation(session_id, generation).await; } - fn cancel_active_quic_generation(&self, session_id: SessionId, generation: u64) { + fn cancel_active_quic_generation(&self, session_id: SessionKey, generation: u64) { let claims = self.claims.lock().expect("flow claim registry poisoned"); for (key, claim) in claims.iter() { if key.session_id == session_id @@ -87,7 +87,7 @@ impl PairingRegistry { self.accepting.store(false, Ordering::Release); } - async fn purge_quic_generation(self: &Arc, session_id: SessionId, generation: u64) { + async fn purge_quic_generation(self: &Arc, session_id: SessionKey, generation: u64) { let mut stale = self .tcp .lock() diff --git a/src/portal/pairing/link.rs b/src/portal/pairing/link.rs index 22a7fd6..9b27615 100644 --- a/src/portal/pairing/link.rs +++ b/src/portal/pairing/link.rs @@ -11,8 +11,8 @@ use std::task::{Context, Poll}; use tokio::io::{AsyncRead, AsyncWrite}; use super::PairingRegistry; +use super::SessionKey; use super::state::{ActiveQuic, BoxReader, BoxWriter}; -use crate::protocol::SessionId; use crate::transport::Stats; struct GuardedReader { @@ -76,7 +76,7 @@ pub(in crate::portal) fn guarded_writer( pub(in crate::portal) struct LinkGuard { registry: Arc, stats: Arc, - session_id: SessionId, + session_id: SessionKey, carrier: crate::protocol::Carrier, quic_generation: Option, } @@ -130,15 +130,14 @@ impl Drop for LinkGuard { } impl PairingRegistry { - pub(in crate::portal) fn register_tcp_link( + pub(in crate::portal) fn register_tcp_link>( self: &Arc, - session_id: SessionId, + session_id: S, stats: Arc, ) -> LinkGuard { + let session_id = session_id.into(); let mut links = self.links.lock().expect("link registry poisoned"); - let counts = links - .entry(session_id) - .or_insert_with(|| super::state::LinkCounts::new(self.max_udp_flows)); + let counts = links.entry(session_id).or_default(); counts.tcp += 1; stats.link_tcp.fetch_add(1, Ordering::Relaxed); drop(links); @@ -152,18 +151,17 @@ impl PairingRegistry { } /// Registers the latest authenticated QUIC carrier for a transport bundle. - pub(in crate::portal) async fn register_quic_link( + pub(in crate::portal) async fn register_quic_link>( self: &Arc, - session_id: SessionId, + session_id: S, stats: Arc, replacement: tokio_util::sync::CancellationToken, ) -> LinkGuard { + let session_id = session_id.into(); let generation = self.next_quic_generation.fetch_add(1, Ordering::Relaxed); let previous = { let mut links = self.links.lock().expect("link registry poisoned"); - let counts = links - .entry(session_id) - .or_insert_with(|| super::state::LinkCounts::new(self.max_udp_flows)); + let counts = links.entry(session_id).or_default(); let previous = counts.udp.replace(ActiveQuic { generation, replacement, diff --git a/src/portal/pairing/state.rs b/src/portal/pairing/state.rs index 4c8a386..eef6c4c 100644 --- a/src/portal/pairing/state.rs +++ b/src/portal/pairing/state.rs @@ -5,21 +5,22 @@ use std::pin::Pin; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use quinn::Connection; -use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot}; use tokio_util::sync::CancellationToken; use crate::protocol::{FlowKind, SessionId, Target}; -pub(in crate::portal) type BoxReader = Pin>; -pub(in crate::portal) type BoxWriter = Pin>; +pub(in crate::portal) type BoxReader = Pin>; +pub(in crate::portal) type BoxWriter = Pin>; + +pub(in crate::portal) type SessionKey = SessionId; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(in crate::portal) struct FlowKey { - pub(in crate::portal) session_id: SessionId, + pub(in crate::portal) session_id: SessionKey, pub(in crate::portal) flow_id: u32, } @@ -164,7 +165,6 @@ pub(in crate::portal) struct PendingUdp { pub(in crate::portal) target: Option, pub(in crate::portal) uplink: Option, pub(in crate::portal) downlink: Option, - pub(in crate::portal) flow_permit: Option>, pub(in crate::portal) uplink_path: Option, pub(in crate::portal) downlink_path: Option, pub(in crate::portal) uplink_generation: Option, @@ -201,15 +201,17 @@ pub(in crate::portal) struct PairedTcp { pub(in crate::portal) struct LinkCounts { pub(in crate::portal) tcp: usize, pub(in crate::portal) udp: Option, - pub(in crate::portal) udp_flow_budget: Arc, + pub(in crate::portal) quic_flows: Arc, + pub(in crate::portal) flow_admission: Arc, } -impl LinkCounts { - pub(in crate::portal) fn new(max_udp_flows: usize) -> Self { +impl Default for LinkCounts { + fn default() -> Self { Self { tcp: 0, udp: None, - udp_flow_budget: Arc::new(Semaphore::new(max_udp_flows)), + quic_flows: Arc::new(AtomicUsize::new(0)), + flow_admission: Arc::new(Semaphore::new(super::SESSION_FLOW_RESOURCE_LIMIT)), } } } @@ -220,6 +222,9 @@ pub(in crate::portal) struct ActiveQuic { } pub(in crate::portal) struct FlowClaim { + pub(in crate::portal) _portal_admission: OwnedSemaphorePermit, + pub(in crate::portal) _session_admission: OwnedSemaphorePermit, + pub(in crate::portal) quic_count: Option>, pub(in crate::portal) epoch: u64, pub(in crate::portal) metadata: Metadata, pub(in crate::portal) target: Option, @@ -228,12 +233,19 @@ pub(in crate::portal) struct FlowClaim { pub(in crate::portal) quic_generations: Vec, } +impl Drop for FlowClaim { + fn drop(&mut self) { + if let Some(count) = &self.quic_count { + count.fetch_sub(1, Ordering::Relaxed); + } + } +} + pub(in crate::portal) struct FlowLease { pub(in crate::portal) registry: std::sync::Weak, pub(in crate::portal) key: FlowKey, pub(in crate::portal) epoch: u64, pub(in crate::portal) cancel: CancellationToken, - pub(in crate::portal) _udp_permit: Option>, } impl FlowLease { diff --git a/src/portal/pairing/tcp.rs b/src/portal/pairing/tcp.rs index 7871609..003f062 100644 --- a/src/portal/pairing/tcp.rs +++ b/src/portal/pairing/tcp.rs @@ -20,9 +20,9 @@ impl PairingRegistry { clippy::too_many_arguments, reason = "the registry boundary keeps each owned stream half explicit" )] - pub(in crate::portal) async fn submit_tcp( + pub(in crate::portal) async fn submit_tcp>( self: &Arc, - session_id: SessionId, + session_id: S, header: FlowHeader, target: Option, link: LinkHalf, @@ -30,6 +30,9 @@ impl PairingRegistry { mut writer: Option, downlink_liveness: Option, ) -> Result, PairingError> { + let session_id = session_id.into(); + let quic_count = self.quic_flow_counter(session_id); + let session_admission = self.session_flow_admission(session_id); if let Err(err) = self.validate_header_and_link(session_id, header, FlowKind::Tcp, target.as_ref(), &link) { @@ -78,6 +81,8 @@ impl PairingRegistry { metadata.clone(), target.clone(), link.quic_generation, + quic_count.clone(), + session_admission.clone(), ) { Ok(claim) => claim, Err(err) => { @@ -96,7 +101,7 @@ impl PairingRegistry { let uplink = reader.expect("duplex TCP reader validated"); let downlink = writer.take().expect("duplex TCP writer validated"); let generations = link.quic_generation.into_iter().collect(); - let lease = match self.activate_claim(key, claim_epoch, generations, None) { + let lease = match self.activate_claim(key, claim_epoch, generations) { Ok(lease) => lease, Err(err) => { self.abandon_claim(key, claim_epoch); @@ -192,6 +197,8 @@ impl PairingRegistry { metadata.clone(), target.clone(), link.quic_generation, + quic_count.clone(), + session_admission.clone(), ) { Ok(claim) => claim, Err(error) => { @@ -271,7 +278,7 @@ impl PairingRegistry { .collect(); drop(links); drop(guard); - let lease = match self.activate_claim(key, epoch, generations, None) { + let lease = match self.activate_claim(key, epoch, generations) { Ok(lease) => lease, Err(error) => { self.abandon_claim(key, epoch); diff --git a/src/portal/pairing/udp.rs b/src/portal/pairing/udp.rs index 11e1b21..103e47e 100644 --- a/src/portal/pairing/udp.rs +++ b/src/portal/pairing/udp.rs @@ -16,14 +16,17 @@ enum UdpInstallOutcome { } impl PairingRegistry { - pub(in crate::portal) async fn submit_udp( + pub(in crate::portal) async fn submit_udp>( self: &Arc, - session_id: SessionId, + session_id: S, header: FlowHeader, target: Option, link: LinkHalf, mut half: UdpHalf, ) -> Result, PairingError> { + let session_id = session_id.into(); + let quic_count = self.quic_flow_counter(session_id); + let session_admission = self.session_flow_admission(session_id); if let Err(err) = self.validate_header_and_link(session_id, header, FlowKind::Udp, target.as_ref(), &link) { @@ -69,28 +72,14 @@ impl PairingRegistry { reject_udp_half(&mut half, code).await; return Err(err); } - let udp_permit = if matches!(header.role, FlowRole::Open | FlowRole::Duplex) { - match self.acquire_udp_permit(session_id) { - Ok(permit) => Some(permit), - Err(err) => { - if header.role == FlowRole::Open { - self.reject_flow_setup(session_id, header.flow_id, err.code()) - .await; - } - reject_udp_half(&mut half, err.code()).await; - return Err(err); - } - } - } else { - None - }; - if header.role == FlowRole::Duplex { let (claim_epoch, created) = match self.reserve_claim( key, metadata.clone(), target.clone(), link.quic_generation, + quic_count.clone(), + session_admission.clone(), ) { Ok(claim) => claim, Err(err) => { @@ -114,7 +103,7 @@ impl PairingRegistry { unreachable!("duplex UDP shape validated") }; let generations = link.quic_generation.into_iter().collect(); - let lease = match self.activate_claim(key, claim_epoch, generations, udp_permit) { + let lease = match self.activate_claim(key, claim_epoch, generations) { Ok(lease) => lease, Err(err) => { self.abandon_claim(key, claim_epoch); @@ -137,7 +126,6 @@ impl PairingRegistry { } let mut half = Some(half); - let mut udp_permit = udp_permit; let outcome = 'install: { let mut guard = self.udp.lock().await; let links = self.links.lock().expect("link registry poisoned"); @@ -159,7 +147,6 @@ impl PairingRegistry { { pending.uplink = None; pending.target = None; - pending.flow_permit = None; pending.uplink_path = None; pending.uplink_generation = None; } @@ -210,6 +197,8 @@ impl PairingRegistry { metadata.clone(), target.clone(), link.quic_generation, + quic_count.clone(), + session_admission.clone(), ) { Ok(claim) => claim, Err(error) => { @@ -227,7 +216,6 @@ impl PairingRegistry { target: target.clone(), uplink: None, downlink: None, - flow_permit: None, uplink_path: None, downlink_path: None, uplink_generation: None, @@ -259,7 +247,6 @@ impl PairingRegistry { }; } pending.uplink = Some(uplink); - pending.flow_permit = udp_permit.take(); pending.uplink_path = Some(link.path); pending.uplink_generation = link.quic_generation; } @@ -283,24 +270,13 @@ impl PairingRegistry { if pending.uplink.is_some() && pending.downlink.is_some() { let mut complete = guard.remove(&key).expect("UDP pair exists"); let epoch = complete.epoch; - let Some(permit) = complete.flow_permit.take() else { - self.abandon_claim(key, epoch); - break 'install UdpInstallOutcome::Rejected { - error: PairingError::new( - FlowErrorCode::InternalError, - "portal::pairing: missing UDP flow permit", - ), - downlink: complete.downlink.take(), - abort_pending: false, - }; - }; let generations = [complete.uplink_generation, complete.downlink_generation] .into_iter() .flatten() .collect(); drop(links); drop(guard); - let lease = match self.activate_claim(key, epoch, generations, Some(permit)) { + let lease = match self.activate_claim(key, epoch, generations) { Ok(lease) => lease, Err(error) => { self.abandon_claim(key, epoch); @@ -394,7 +370,12 @@ impl PairingRegistry { }); } - pub(in crate::portal) async fn cancel_udp(&self, session_id: SessionId, flow_id: u32) { + pub(in crate::portal) async fn cancel_udp>( + &self, + session_id: S, + flow_id: u32, + ) { + let session_id = session_id.into(); let key = FlowKey { session_id, flow_id, diff --git a/src/portal/runtime.rs b/src/portal/runtime.rs index d08c58f..fab2570 100644 --- a/src/portal/runtime.rs +++ b/src/portal/runtime.rs @@ -65,6 +65,45 @@ impl Portal { Ok(listeners) => listeners, Err(error) => return self.start_failed(error), }; + for endpoint in &endpoints { + if let Ok(address) = endpoint.local_addr() { + self.inner + .logger + .info(format_args!("portal::run: listening on QUIC/UDP {address}")); + } + } + for listener in &tcp_listeners { + if let Ok(address) = listener.local_addr() { + self.inner + .logger + .info(format_args!("portal::run: listening on TLS/TCP {address}")); + } + } + let addresses = |addrs: Vec| { + if addrs.is_empty() { + "none".to_owned() + } else { + addrs + .iter() + .map(ToString::to_string) + .collect::>() + .join(",") + } + }; + self.inner.telemetry.set_listening_addresses( + &addresses( + tcp_listeners + .iter() + .map(TcpListener::local_addr) + .collect::>()?, + ), + &addresses( + endpoints + .iter() + .map(Endpoint::local_addr) + .collect::>()?, + ), + ); let telemetry_shutdown = CancellationToken::new(); let mut telemetry_tasks: JoinSet<()> = JoinSet::new(); match TelemetryServer::bind(self.inner.telemetry.clone()) { @@ -293,14 +332,13 @@ impl Portal { /// Returns the effective startup URL that is logged for operators. pub(super) fn effective_url(&self) -> String { let base = format!( - "portal://{}?net={}&tls={}&alpn={}&rate={}&etar={}&dial={}&socks={}&next={}", + "portal://{}?tls={}&rate={}&etar={}&dial={}&morph={}&socks={}&next={}", self.inner.endpoint_addr, - self.inner.network_mode, self.inner.tls_mode, - self.inner.alpn, self.inner.rate_limit, self.inner.etar_limit, self.inner.outbound.dialer_ip(), + u8::from(self.inner.morph_keys.is_some()), self.inner.outbound.socks_endpoint(), self.inner.outbound.next_endpoint(), ); @@ -308,7 +346,12 @@ impl Portal { .outbound .next_transport() .map_or(base.clone(), |transport| { - format!("{base}&{}", transport.replace(' ', "&")) + let upstream = transport + .split_whitespace() + .filter(|option| !option.starts_with("morph=")) + .collect::>() + .join("&"); + format!("{base}&{upstream}") }) } @@ -317,12 +360,18 @@ impl Portal { if !self.inner.network_mode.listens_udp() { return Ok(Vec::new()); } - self.inner - .bind_addrs - .iter() - .copied() - .map(|addr| listen_endpoint(self.inner.quic_server_config.clone(), addr)) - .collect() + bind_carrier( + &self.inner.udp_bind_addrs, + self.inner.allow_udp_family_degrade, + |addr| listen_endpoint( + self.inner.quic_server_config.clone(), + addr, + self.inner.morph_keys.clone(), + ), + |addr, error| self.inner.logger.warn(format_args!( + "portal::listen_endpoints: UDP address family unavailable for {addr}; continuing: {error:#}" + )), + ).context("portal::listen_endpoints: failed to open UDP listeners") } /// Opens TLS/TCP listeners for network modes that accept TCP service. @@ -330,13 +379,55 @@ impl Portal { if !self.inner.network_mode.listens_tcp() { return Ok(Vec::new()); } - self.inner - .bind_addrs - .iter() - .copied() - .map(listen_tcp) - .collect() + bind_carrier( + &self.inner.tcp_bind_addrs, + self.inner.allow_tcp_family_degrade, + listen_tcp, + |addr, error| self.inner.logger.warn(format_args!( + "portal::listen_tcp_listeners: TCP address family unavailable for {addr}; continuing: {error:#}" + )), + ).context("portal::listen_tcp_listeners: failed to open TCP listeners") + } +} + +/// Owns every successful bind until the whole carrier has passed validation. +fn bind_carrier( + addresses: &[std::net::SocketAddr], + allow_degrade: bool, + mut bind: impl FnMut(std::net::SocketAddr) -> Result, + mut warn: impl FnMut(std::net::SocketAddr, &anyhow::Error), +) -> Result> { + let mut listeners = Vec::new(); + for &address in addresses { + match bind(address) { + Ok(listener) => listeners.push(listener), + Err(error) if allow_degrade && family_is_unavailable(&error) => warn(address, &error), + Err(error) => return Err(error), + } + } + if listeners.is_empty() { + anyhow::bail!("no declared address could be bound"); } + Ok(listeners) +} + +fn family_is_unavailable(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(io_error_is_family_unavailable) + }) +} + +fn io_error_is_family_unavailable(error: &std::io::Error) -> bool { + #[cfg(unix)] + const FAMILY_UNAVAILABLE: i32 = libc::EAFNOSUPPORT; + #[cfg(windows)] + const FAMILY_UNAVAILABLE: i32 = 10047; // WSAEAFNOSUPPORT + matches!( + error.kind(), + std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::Unsupported + ) || error.raw_os_error() == Some(FAMILY_UNAVAILABLE) } fn listener_exit_error( @@ -351,3 +442,7 @@ fn listener_exit_error( None => anyhow::anyhow!("portal::run: {name} listener set became empty unexpectedly"), } } + +#[cfg(test)] +#[path = "../tests/portal/runtime.rs"] +mod family_error_tests; diff --git a/src/portal/setup.rs b/src/portal/setup.rs index 8bc41c5..1ad9366 100644 --- a/src/portal/setup.rs +++ b/src/portal/setup.rs @@ -11,19 +11,21 @@ use url::Url; use crate::common::{ DEFAULT_RATE_LIMIT, LifeMode, LifeReason, LifeState, Lifecycle, Logger, OutboundDialer, - SocksConfig, bind_udp_addrs, first_raw_query_value, init_dialer_ip, - new_server_configs_with_reload_interval, parse_alpn, query_first, rate_limit_bytes_per_second, + ServiceEndpoint, SocksConfig, first_raw_query_value, init_dialer_ip, + new_server_configs_with_reload_interval, query_first, rate_limit_bytes_per_second, + resolve_bind_addrs, }; use crate::protocol::Credentials; use crate::telemetry::{InstanceRole, TelemetryHub}; +use crate::transport::MorphKeys; use crate::transport::{Buffers, RateLimiter, Stats}; use crate::vector::{PortalClient, PortalClientConfig}; -use super::listener::{configure_transport, format_endpoint_addr}; +use super::listener::configure_transport; use super::{NetworkMode, Portal, PortalInner, UdpFlowLimits, admission, outbound::PortalOutbound}; const PORTAL_QUERY_PARAMETERS: &[&str] = &[ - "net", "tls", "crt", "key", "alpn", "rate", "etar", "dial", "socks", "next", "log", + "tls", "crt", "key", "rate", "etar", "dial", "morph", "socks", "next", "log", ]; const PORTAL_UPSTREAM_PARAMETERS: &[&str] = &["up", "down", "mux", "sni", "pin"]; @@ -59,81 +61,91 @@ impl Portal { lifecycle: Arc, ) -> Result { if parsed_url.scheme() != "portal" { - anyhow::bail!("portal::new: URL scheme must be portal"); + anyhow::bail!("Portal configuration: URL scheme must be portal"); } if parsed_url.password().is_some() { - anyhow::bail!("portal::new: password userinfo is not supported"); + anyhow::bail!("Portal configuration: password userinfo is not supported"); } if parsed_url.fragment().is_some() { - anyhow::bail!("portal::new: URL fragments are not supported"); - } - if !parsed_url.path().is_empty() { - anyhow::bail!("portal::new: URL paths are not supported"); + anyhow::bail!("Portal configuration: URL fragments are not supported"); } let mut query = query_first(&parsed_url, PORTAL_QUERY_PARAMETERS) - .map_err(|e| anyhow::anyhow!("portal::new: {e}"))?; - validate_query(&query).map_err(|e| anyhow::anyhow!("portal::new: {e}"))?; - let port = parsed_url - .port() - .ok_or_else(|| anyhow::anyhow!("portal::new: missing listen port"))?; - if port == 0 { - anyhow::bail!("portal::new: listen port must be non-zero"); + .map_err(|e| anyhow::anyhow!("Portal configuration: invalid query: {e}"))?; + validate_query(&query).map_err(|e| anyhow::anyhow!("Portal configuration: {e}"))?; + let mut service_endpoint = ServiceEndpoint::parse(&parsed_url, true, "Portal endpoint")?; + let bind_host = listen_host + .unwrap_or(service_endpoint.host.as_str()) + .to_owned(); + if listen_host == Some("") { + service_endpoint.host = "*".to_owned(); } - let credentials = - Credentials::new(&parsed_url).map_err(|e| anyhow::anyhow!("portal::new: {e}"))?; + let credentials = Credentials::new(&parsed_url)?; + let morph = query.get("morph").is_some_and(|value| value == "1"); + let morph_keys = morph + .then(|| MorphKeys::from_url(&parsed_url)) + .transpose()?; let runtime = super::config::PortalRuntimeConfig::from_env() - .map_err(|e| anyhow::anyhow!("portal::new: invalid runtime configuration: {e}"))?; - let alpn = parse_alpn(query.get("alpn").map(String::as_str)) - .map_err(|error| anyhow::anyhow!("portal::new: {error}"))?; + .map_err(|e| anyhow::anyhow!("Portal configuration: invalid runtime setting: {e}"))?; let network_mode = - NetworkMode::from_url(&parsed_url).map_err(|e| anyhow::anyhow!("portal::new: {e}"))?; - let (tls_mode, tls_server_config, mut quic_server_config) = - new_server_configs_with_reload_interval( - &parsed_url, - &alpn, - runtime.reload_interval, - logger.clone(), - ) - .map_err(|e| anyhow::anyhow!("portal::new: {e}"))?; - - let host = listen_host.unwrap_or_else(|| parsed_url.host_str().unwrap_or_default()); - let endpoint_addr = format_endpoint_addr(host, port); - let bind_addrs = bind_udp_addrs(host, port) - .map_err(|e| anyhow::anyhow!("portal::new: failed to bind listen address: {e}"))?; - + NetworkMode::from_carriers(service_endpoint.has_tcp(), service_endpoint.has_udp()); let dialer_ip = init_dialer_ip(query.get("dial").map(String::as_str)); - let socks = SocksConfig::from_url(&parsed_url).map_err(|e| { - anyhow::anyhow!("portal::new: failed to parse socks configuration: {e}") - })?; + let socks = SocksConfig::from_url(&parsed_url) + .map_err(|e| anyhow::anyhow!("Portal configuration: invalid socks parameter: {e}"))?; let next = match query.get("next").map(String::as_str) { None | Some("none") => None, - Some("") => anyhow::bail!("portal::new: empty next parameter"), + Some("") => anyhow::bail!("Portal configuration: next must not be empty"), Some(_) => { query.extend( - query_first(&parsed_url, PORTAL_UPSTREAM_PARAMETERS) - .map_err(|e| anyhow::anyhow!("portal::new: {e}"))?, + query_first(&parsed_url, PORTAL_UPSTREAM_PARAMETERS).map_err(|e| { + anyhow::anyhow!("Portal configuration: invalid upstream query: {e}") + })?, ); let raw = first_raw_query_value(&parsed_url, "next") .expect("decoded next came from the raw query"); - Some( - PortalClientConfig::from_upstream_authority(raw, &query, &dialer_ip) - .map_err(|error| anyhow::anyhow!("portal::new: {error}"))?, - ) + Some(PortalClientConfig::from_upstream_authority( + raw, &query, &dialer_ip, + )?) } }; if socks.is_some() && next.is_some() { - anyhow::bail!("portal::new: socks and next are mutually exclusive"); + anyhow::bail!("Portal configuration: socks and next are mutually exclusive"); } let rate_limit = parse_rate(&query, "rate")?; let etar_limit = parse_rate(&query, "etar")?; - configure_transport(&mut quic_server_config, runtime.udp_idle_timeout, None)?; + let (tls_mode, tls_server_config, mut quic_server_config) = + new_server_configs_with_reload_interval( + &parsed_url, + runtime.reload_interval, + logger.clone(), + ) + .map_err(|e| anyhow::anyhow!("Portal configuration: invalid TLS settings: {e}"))?; + + let endpoint_addr = service_endpoint.canonical(); + let tcp_bind_addrs = service_endpoint + .tcp + .map(|endpoint| resolve_bind_addrs(&bind_host, endpoint)) + .transpose() + .map_err(|e| anyhow::anyhow!("Portal endpoint: failed to resolve TCP address: {e}"))? + .unwrap_or_default(); + let udp_bind_addrs = service_endpoint + .udp + .map(|endpoint| resolve_bind_addrs(&bind_host, endpoint)) + .transpose() + .map_err(|e| anyhow::anyhow!("Portal endpoint: failed to resolve UDP address: {e}"))? + .unwrap_or_default(); + + configure_transport( + &mut quic_server_config, + runtime.udp_idle_timeout, + None, + morph_keys.is_some(), + )?; let read_bps = rate_limit_bytes_per_second(rate_limit) as i64; let write_bps = rate_limit_bytes_per_second(etar_limit) as i64; let rate_limiter = RateLimiter::new(read_bps, write_bps).map(Arc::new); let udp_flow_limits = UdpFlowLimits { - max_flows: runtime.max_udp_flows, queue_bytes: runtime.udp_queue_bytes, }; let socks_endpoint = socks @@ -145,7 +157,8 @@ impl Portal { |(config, _)| format!("next={} {}", config.endpoint(), config.effective_route()), ); let telemetry_summary = format!( - "net={network_mode} tls={tls_mode} alpn={alpn} rate={rate_limit} etar={etar_limit} dial={dialer_ip} socks={socks_endpoint} {next_summary}", + "listen={endpoint_addr} tls={tls_mode} rate={rate_limit} etar={etar_limit} dial={dialer_ip} morph={} socks={socks_endpoint} {next_summary}", + u8::from(morph), ); let telemetry = TelemetryHub::for_current_process( InstanceRole::Portal, @@ -168,12 +181,21 @@ impl Portal { Ok(Self { inner: Arc::new(PortalInner { credentials, - alpn, + morph_keys, tls_mode, network_mode, endpoint_addr, - bind_addrs, - listen_port: port, + tcp_bind_addrs, + udp_bind_addrs, + allow_tcp_family_degrade: service_endpoint.host == "*" + && service_endpoint.tcp.is_some_and(|endpoint| { + endpoint.family == crate::common::AddressFamily::Any + }), + allow_udp_family_degrade: service_endpoint.host == "*" + && service_endpoint.udp.is_some_and(|endpoint| { + endpoint.family == crate::common::AddressFamily::Any + }), + udp_listen_port: service_endpoint.udp.map(|endpoint| endpoint.port), outbound, rate_limit, etar_limit, @@ -190,9 +212,6 @@ impl Portal { quic_server_config, unauthenticated_admission: Arc::new(admission::UnauthenticatedAdmission::new()), pairing: Arc::new(super::pairing::PairingRegistry::new( - runtime.max_tcp_flows as usize, - udp_flow_limits.max_flows, - runtime.max_pending_pairs, runtime.flow_pair_timeout, )), ready_gate: super::tasks::ReadyGate::default(), @@ -204,9 +223,7 @@ impl Portal { } fn validate_query(query: &std::collections::HashMap) -> Result<()> { - for name in [ - "log", "tls", "crt", "key", "alpn", "net", "rate", "etar", "dial", "socks", - ] { + for name in ["log", "tls", "crt", "key", "rate", "etar", "dial", "socks"] { if query.get(name).is_some_and(String::is_empty) { anyhow::bail!("empty {name} parameter"); } @@ -224,10 +241,10 @@ fn validate_query(query: &std::collections::HashMap) -> Result<( { anyhow::bail!("tls=1 or tls=2 required"); } - if let Some(net) = query.get("net") - && !matches!(net.as_str(), "mix" | "tcp" | "udp") + if let Some(morph) = query.get("morph") + && !matches!(morph.as_str(), "0" | "1") { - anyhow::bail!("invalid net mode"); + anyhow::bail!("morph must be 0 or 1"); } let tls_is_ca = query.get("tls").is_some_and(|value| value == "2"); let has_crt = query.contains_key("crt"); @@ -250,6 +267,6 @@ fn parse_rate(query: &std::collections::HashMap, name: &str) -> .parse::() .ok() .filter(|value| *value >= 0) - .ok_or_else(|| anyhow::anyhow!("invalid {name} rate limit")) + .ok_or_else(|| anyhow::anyhow!("{name} must be a non-negative integer")) }) } diff --git a/src/protocol/auth.rs b/src/protocol/auth.rs index d62bf0d..dd31718 100644 --- a/src/protocol/auth.rs +++ b/src/protocol/auth.rs @@ -20,7 +20,7 @@ pub const AUTH_TAG_LEN: usize = 16; /// Length of the fixed authentication frame. pub const AUTH_FRAME_LEN: usize = SESSION_ID_LEN + AUTH_TAG_LEN; -const AUTH_ROOT_SALT_LABEL: &[u8] = b"nowhere/now/1/auth-root"; +const AUTH_ROOT_SALT_LABEL: &[u8] = b"nowhere/nw2/auth-root"; const AUTH_KEY_INFO: &[u8] = b"authentication"; /// Authentication key derived once from the configured shared key. @@ -51,19 +51,33 @@ impl Credentials { /// Parses the shared key from the URL username and derives its auth key. pub fn new(parsed_url: &Url) -> Result { if parsed_url.password().is_some() { - bail!("protocol::auth::Credentials::new: password credentials are not supported"); + bail!("password credentials are not supported; put the shared key before '@'"); } - let shared_key = decode_url_username(parsed_url)?; + let shared_key = Self::decode_shared_key(parsed_url)?; Self::from_shared_key(&shared_key) } + pub(crate) fn decode_shared_key(parsed_url: &Url) -> Result> { + if parsed_url.password().is_some() { + bail!("password credentials are not supported; put the shared key before '@'"); + } + let shared_key = decode_url_username(parsed_url)?; + if shared_key.is_empty() { + bail!("missing shared key before '@'"); + } + if shared_key.len() > u8::MAX as usize { + bail!("shared key exceeds the 255-byte limit"); + } + Ok(shared_key) + } + /// Derives credentials directly from non-empty shared-key bytes. pub fn from_shared_key(shared_key: &[u8]) -> Result { if shared_key.is_empty() { - bail!("protocol::auth::Credentials::from_shared_key: missing shared key"); + bail!("missing shared key before '@'"); } if shared_key.len() > u8::MAX as usize { - bail!("protocol::auth::Credentials::from_shared_key: shared key exceeds 255 bytes"); + bail!("shared key exceeds the 255-byte limit"); } Ok(Self { auth_key: derive_auth_key(shared_key), diff --git a/src/protocol/datagram.rs b/src/protocol/datagram.rs index 7f49ca5..bc409a9 100644 --- a/src/protocol/datagram.rs +++ b/src/protocol/datagram.rs @@ -8,21 +8,21 @@ use bytes::Bytes; use super::FlowId; -/// Unfragmented DATA frame type in bits 0..1. +/// Unfragmented DATA frame type in the high two bits. pub const UDP_FRAME_DATA: u8 = 0; -/// Fragmented DATA frame type in bits 0..1. +/// Fragmented DATA frame type in the high two bits. pub const UDP_FRAME_FRAGMENT: u8 = 1; -/// Flow CLOSE frame type in bits 0..1. +/// Flow CLOSE frame type in the high two bits. pub const UDP_FRAME_CLOSE: u8 = 2; /// Common unfragmented/CLOSE header length. -pub const UDP_HEADER_LEN: usize = 5; +pub const UDP_HEADER_LEN: usize = 4; /// Fragment header length. -pub const UDP_FRAGMENT_HEADER_LEN: usize = 13; +pub const UDP_FRAGMENT_HEADER_LEN: usize = 12; /// Largest UDP payload representable by the protocol. pub const UDP_PACKET_MAX: usize = u16::MAX as usize; -const FRAME_TYPE_MASK: u8 = 0b0000_0011; -const RESERVED_MASK: u8 = 0b1111_1100; +const FRAME_TYPE_SHIFT: u32 = 30; +const FRAME_TYPE_MASK: u32 = 0b11 << FRAME_TYPE_SHIFT; /// Fragment metadata parameterized by borrowed or owned payload storage. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -101,12 +101,11 @@ pub fn encode_udp_fragment_header( "encode_udp_fragment_header", )?; let mut output = [0; UDP_FRAGMENT_HEADER_LEN]; - output[0] = UDP_FRAME_FRAGMENT; - output[1..5].copy_from_slice(&flow_id.to_be_bytes()); - output[5..9].copy_from_slice(&packet_id.to_be_bytes()); - output[9] = fragment_index; - output[10] = fragment_count; - output[11..13].copy_from_slice(&total_len.to_be_bytes()); + output[..4].copy_from_slice(&encode_base_word(UDP_FRAME_FRAGMENT, flow_id)?.to_be_bytes()); + output[4..8].copy_from_slice(&packet_id.to_be_bytes()); + output[8] = fragment_index; + output[9] = fragment_count; + output[10..12].copy_from_slice(&total_len.to_be_bytes()); Ok(output) } @@ -226,21 +225,19 @@ pub fn decode_udp_frame(input: &[u8]) -> Result> { if input.len() < UDP_HEADER_LEN { bail!("protocol::datagram::decode_udp_frame: short header"); } - let flags = input[0]; - if flags & RESERVED_MASK != 0 { - bail!("protocol::datagram::decode_udp_frame: reserved flags are non-zero"); - } - let flow_id = u32::from_be_bytes(input[1..5].try_into().expect("fixed flow id")); + let base = u32::from_be_bytes(input[..4].try_into().expect("fixed base header")); + let frame_type = (base & FRAME_TYPE_MASK) >> FRAME_TYPE_SHIFT; + let flow_id = base & super::MAX_FLOW_ID; validate_flow_id(flow_id, "decode_udp_frame")?; - match flags & FRAME_TYPE_MASK { - UDP_FRAME_DATA => { + match frame_type { + value if value == UDP_FRAME_DATA as u32 => { let payload = &input[UDP_HEADER_LEN..]; validate_udp_payload(payload, "decode_udp_frame")?; Ok(UdpFrame::Data { flow_id, payload }) } - UDP_FRAME_FRAGMENT => decode_fragment(input, flow_id), - UDP_FRAME_CLOSE => { + value if value == UDP_FRAME_FRAGMENT as u32 => decode_fragment(input, flow_id), + value if value == UDP_FRAME_CLOSE as u32 => { if input.len() != UDP_HEADER_LEN { bail!("protocol::datagram::decode_udp_frame: CLOSE payload"); } @@ -275,11 +272,11 @@ fn decode_fragment(input: &[u8], flow_id: FlowId) -> Result> { if input.len() < UDP_FRAGMENT_HEADER_LEN { bail!("protocol::datagram::decode_udp_frame: short fragment header"); } - let packet_id = u32::from_be_bytes(input[5..9].try_into().expect("fixed packet id")); + let packet_id = u32::from_be_bytes(input[4..8].try_into().expect("fixed packet id")); validate_packet_id(packet_id, "decode_udp_frame")?; - let fragment_index = input[9]; - let fragment_count = input[10]; - let total_len = u16::from_be_bytes([input[11], input[12]]); + let fragment_index = input[8]; + let fragment_count = input[9]; + let total_len = u16::from_be_bytes([input[10], input[11]]); validate_fragment_metadata( fragment_index, fragment_count, @@ -305,16 +302,20 @@ fn decode_fragment(input: &[u8], flow_id: FlowId) -> Result> { } fn encode_base_header(frame_type: u8, flow_id: FlowId) -> Result<[u8; UDP_HEADER_LEN]> { + Ok(encode_base_word(frame_type, flow_id)?.to_be_bytes()) +} + +fn encode_base_word(frame_type: u8, flow_id: FlowId) -> Result { validate_flow_id(flow_id, "encode_base_header")?; - let mut output = [0; UDP_HEADER_LEN]; - output[0] = frame_type; - output[1..].copy_from_slice(&flow_id.to_be_bytes()); - Ok(output) + if frame_type > UDP_FRAME_CLOSE { + bail!("protocol::datagram::encode_base_header: invalid frame type"); + } + Ok((u32::from(frame_type) << FRAME_TYPE_SHIFT) | flow_id) } fn validate_flow_id(flow_id: FlowId, operation: &str) -> Result<()> { - if flow_id == 0 { - bail!("protocol::datagram::{operation}: zero flow id"); + if flow_id == 0 || flow_id > super::MAX_FLOW_ID { + bail!("protocol::datagram::{operation}: flow id out of range"); } Ok(()) } diff --git a/src/protocol/datagram/reassembly.rs b/src/protocol/datagram/reassembly.rs index f7d20d1..b537b1a 100644 --- a/src/protocol/datagram/reassembly.rs +++ b/src/protocol/datagram/reassembly.rs @@ -162,6 +162,7 @@ impl DatagramReassembler { F: FnOnce(u16) -> Option, { if flow_id == 0 + || flow_id > crate::protocol::MAX_FLOW_ID || fragment.packet_id == 0 || validate_fragment_metadata( fragment.fragment_index, @@ -235,26 +236,34 @@ impl DatagramReassembler { if self.config.max_slots == 0 || fragment.total_len as usize > self.config.max_bytes { return ReassemblyOutcome::Dropped(ReassemblyDropReason::ByteLimit); } - if self.slots.len() >= self.config.max_slots - && let Some(oldest) = self - .slots + let oldest = if self.slots.len() >= self.config.max_slots { + self.slots .iter() .min_by_key(|(_, slot)| slot.created_at) .map(|(key, _)| *key) - { - self.remove_slot(&oldest); - evicted_partial = true; - } + } else { + None + }; + let replaced_bytes = oldest + .and_then(|key| self.slots.get(&key)) + .map_or(0, |slot| slot.total_len as usize); if self .reserved_bytes + .saturating_sub(replaced_bytes) .saturating_add(fragment.total_len as usize) > self.config.max_bytes { return ReassemblyOutcome::Dropped(ReassemblyDropReason::ByteLimit); } + // External admission is fallible. Reserve before evicting so a failed + // replacement cannot discard an otherwise valid partial packet. let Some(reservation) = reserve(fragment.total_len) else { return ReassemblyOutcome::Dropped(ReassemblyDropReason::ByteLimit); }; + if let Some(oldest) = oldest { + self.remove_slot(&oldest); + evicted_partial = true; + } self.reserved_bytes += fragment.total_len as usize; let expiry = now.checked_add(self.config.ttl).unwrap_or(now); self.next_expiry = Some( diff --git a/src/protocol/flow.rs b/src/protocol/flow.rs index 27efd68..4f93b7a 100644 --- a/src/protocol/flow.rs +++ b/src/protocol/flow.rs @@ -22,6 +22,8 @@ pub const MAX_PORTAL_HOPS: u8 = 7; pub type SessionId = [u8; SESSION_ID_LEN]; /// Flow identifier scoped to one logical session. pub type FlowId = u32; +/// Largest logical-flow identifier representable by every V2 carrier. +pub const MAX_FLOW_ID: FlowId = 0x3fff_ffff; /// Relationship of the current physical lane to a logical flow. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -70,8 +72,8 @@ impl FlowHeader { if self.hops > MAX_PORTAL_HOPS { bail!("protocol::flow::FlowHeader::validate: hop budget exceeds {MAX_PORTAL_HOPS}") } - if self.flow_id == 0 { - bail!("protocol::flow::FlowHeader::validate: zero flow id"); + if self.flow_id == 0 || self.flow_id > MAX_FLOW_ID { + bail!("protocol::flow::FlowHeader::validate: flow id out of range"); } match self.role { FlowRole::Duplex if self.uplink != self.downlink => { @@ -102,16 +104,14 @@ impl FlowHeader { /// Encodes a header after validating its semantic invariants. pub fn encode_flow_header(header: FlowHeader) -> Result<[u8; FLOW_HEADER_LEN]> { - header.validate()?; - Ok(write_flow_header(header)) + write_flow_header(header) } /// Encodes a flow header into a fixed stack array. /// -/// Callers accepting untrusted or dynamically assembled metadata should use -/// [`encode_flow_header`] first. This low-level spelling remains allocation-free -/// and matches the existing request-building call sites. -pub fn write_flow_header(header: FlowHeader) -> [u8; FLOW_HEADER_LEN] { +/// Validates the same semantic invariants as [`encode_flow_header`]. +pub fn write_flow_header(header: FlowHeader) -> Result<[u8; FLOW_HEADER_LEN]> { + header.validate()?; let flags = header.role as u8 | (header.kind as u8) << 2 | (header.uplink as u8) << 3 @@ -120,7 +120,7 @@ pub fn write_flow_header(header: FlowHeader) -> [u8; FLOW_HEADER_LEN] { let mut output = [0; FLOW_HEADER_LEN]; output[0] = flags; output[1..].copy_from_slice(&header.flow_id.to_be_bytes()); - output + Ok(output) } /// Decodes exactly one fixed flow header. diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 215d512..4a59b43 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -24,7 +24,7 @@ pub use datagram::{ encode_udp_fragments, }; pub use flow::{ - Carrier, FLOW_HEADER_LEN, FlowHeader, FlowId, FlowKind, FlowRole, MAX_PORTAL_HOPS, + Carrier, FLOW_HEADER_LEN, FlowHeader, FlowId, FlowKind, FlowRole, MAX_FLOW_ID, MAX_PORTAL_HOPS, SESSION_ID_LEN, SessionId, decode_flow_header, encode_flow_header, read_flow_header, write_flow_header, }; @@ -42,3 +42,5 @@ pub use uot::{ UOT_HEADER_LEN, UOT_PACKET_MAX, encode_udp_packet, encode_udp_packet_header, read_udp_packet, read_udp_packet_into, write_udp_packet, }; +/// The sole ALPN accepted by Nowhere 2 carriers. +pub const ALPN: &[u8] = b"nw2"; diff --git a/src/protocol/util.rs b/src/protocol/util.rs index 8daa8fd..0f3c38f 100644 --- a/src/protocol/util.rs +++ b/src/protocol/util.rs @@ -20,7 +20,7 @@ pub(super) fn decode_url_username(parsed_url: &Url) -> Result> { || !bytes[index + 1].is_ascii_hexdigit() || !bytes[index + 2].is_ascii_hexdigit() { - bail!("protocol::auth::Credentials::new: malformed percent escape"); + bail!("malformed percent escape in shared key"); } index += 3; } else { @@ -29,7 +29,7 @@ pub(super) fn decode_url_username(parsed_url: &Url) -> Result> { } let decoded = percent_decode_str(username) .decode_utf8() - .context("protocol::auth::Credentials::new: shared key is not valid UTF-8")?; + .context("shared key is not valid UTF-8")?; Ok(decoded.as_bytes().to_vec()) } diff --git a/src/telemetry/hub.rs b/src/telemetry/hub.rs index 699645e..e751e68 100644 --- a/src/telemetry/hub.rs +++ b/src/telemetry/hub.rs @@ -4,7 +4,7 @@ //! In-process structured telemetry publisher. use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use tokio::sync::{broadcast, watch}; @@ -23,6 +23,7 @@ const EVENT_CAPACITY: usize = 1_024; /// The in-process publisher shared by runtime orchestration and every flow. pub(crate) struct TelemetryHub { descriptor: InstanceDescriptor, + listening_descriptor: OnceLock, lifecycle: watch::Sender, snapshots: watch::Sender, events: broadcast::Sender, @@ -69,6 +70,7 @@ impl TelemetryHub { let (events, _) = broadcast::channel(EVENT_CAPACITY); Arc::new(Self { descriptor, + listening_descriptor: OnceLock::new(), lifecycle, snapshots, events, @@ -81,7 +83,16 @@ impl TelemetryHub { } pub(crate) fn descriptor(&self) -> &InstanceDescriptor { - &self.descriptor + self.listening_descriptor.get().unwrap_or(&self.descriptor) + } + + /// Publishes actual bound addresses before the telemetry server starts. + pub(crate) fn set_listening_addresses(&self, tcp: &str, udp: &str) { + let mut descriptor = self.descriptor.clone(); + descriptor + .config_summary + .push_str(&format!(" tcp={tcp} udp={udp}")); + let _ = self.listening_descriptor.set(descriptor); } pub(crate) fn unavailable_reason(&self) -> Option<&str> { @@ -173,7 +184,6 @@ impl TelemetryHub { id: start.id, timestamp_ms: start.timestamp_ms, protocol: start.protocol, - alpn: start.alpn, flow_id: start.flow_id, session_tag: start.session_tag, client: start.client, @@ -213,7 +223,6 @@ impl TelemetryHub { timestamp_ms: now_unix_ms(), duration_ms: started_at.elapsed().as_millis().min(u64::MAX as u128) as u64, protocol: started.protocol, - alpn: started.alpn.clone(), flow_id: started.flow_id, session_tag: started.session_tag.clone(), client: started.client.clone(), diff --git a/src/telemetry/ipc.rs b/src/telemetry/ipc.rs index 2e33088..9e117de 100644 --- a/src/telemetry/ipc.rs +++ b/src/telemetry/ipc.rs @@ -20,7 +20,7 @@ use tokio_util::sync::CancellationToken; use super::process::{process_is_alive, process_uid, read_process_incarnation}; use super::{ - ClientMessage, Hello, MAX_FRAME_SIZE, PROTOCOL_VERSION, ServerMessage, Subscription, + ClientMessage, Hello, MAX_FRAME_SIZE, ServerMessage, Subscription, TELEMETRY_VERSION, TelemetryHub, }; @@ -291,7 +291,7 @@ impl Drop for TelemetryServer { fn registry_directory() -> PathBuf { std::env::temp_dir().join(format!( - "nowhere-telemetry-v{PROTOCOL_VERSION}-{}", + "nowhere-{TELEMETRY_VERSION}-telemetry-{}", process_uid() )) } @@ -302,7 +302,7 @@ fn registry_path(registry_name: &str) -> PathBuf { fn validate_hello(hello: &Hello, discovered: &DiscoveredInstance) -> Result<()> { let instance = &hello.instance; - if instance.protocol_version != PROTOCOL_VERSION + if instance.telemetry_version != TELEMETRY_VERSION || instance.uid != discovered.uid || instance.pid != discovered.pid || instance.incarnation != discovered.incarnation diff --git a/src/telemetry/mod.rs b/src/telemetry/mod.rs index 27883b7..31b52ce 100644 --- a/src/telemetry/mod.rs +++ b/src/telemetry/mod.rs @@ -19,6 +19,6 @@ pub(crate) use ipc::{DiscoveredInstance, TelemetryClient, TelemetryServer, disco pub(crate) use process::now_unix_ms; pub(crate) use wire::{ AccessFinished, AccessOutcome, AccessStart, AccessStarted, ClientMessage, Hello, InstanceRole, - MAX_FRAME_SIZE, PROTOCOL_VERSION, RuntimeEvent, RuntimeKind, RuntimeLevel, ServerMessage, - Subscription, TelemetrySnapshot, TrafficProtocol, + MAX_FRAME_SIZE, RuntimeEvent, RuntimeKind, RuntimeLevel, ServerMessage, Subscription, + TELEMETRY_VERSION, TelemetrySnapshot, TrafficProtocol, }; diff --git a/src/telemetry/wire.rs b/src/telemetry/wire.rs index 387919d..3c8c026 100644 --- a/src/telemetry/wire.rs +++ b/src/telemetry/wire.rs @@ -12,8 +12,8 @@ use crate::protocol::Carrier; use super::process::{now_unix_ms, process_incarnation, process_uid}; -/// Version of the local telemetry protocol. It is not a data-plane wire version. -pub(crate) const PROTOCOL_VERSION: u16 = 2; +/// Local telemetry generation, aligned with the Nowhere application major version. +pub(crate) const TELEMETRY_VERSION: u16 = 2; /// Maximum accepted JSON payload, excluding the four-byte length prefix. pub(crate) const MAX_FRAME_SIZE: usize = 64 * 1024; @@ -27,7 +27,7 @@ pub(crate) enum InstanceRole { /// Non-secret metadata identifying a single process incarnation. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub(crate) struct InstanceDescriptor { - pub(crate) protocol_version: u16, + pub(crate) telemetry_version: u16, pub(crate) id: String, pub(crate) role: InstanceRole, pub(crate) pid: u32, @@ -50,7 +50,7 @@ impl InstanceDescriptor { let uid = process_uid(); let incarnation = process_incarnation(pid)?; Ok(Self { - protocol_version: PROTOCOL_VERSION, + telemetry_version: TELEMETRY_VERSION, id: format!("{uid}:{pid}:{incarnation}"), role, pid, @@ -65,8 +65,8 @@ impl InstanceDescriptor { pub(crate) fn registry_name(&self) -> String { format!( - "nowhere.v{}.{}.{}.{}", - PROTOCOL_VERSION, self.uid, self.pid, self.incarnation + "nowhere.{}.{}.{}.{}", + TELEMETRY_VERSION, self.uid, self.pid, self.incarnation ) } @@ -79,7 +79,7 @@ impl InstanceDescriptor { let pid = std::process::id(); let uid = process_uid(); Self { - protocol_version: PROTOCOL_VERSION, + telemetry_version: TELEMETRY_VERSION, id: format!("{uid}:{pid}:unavailable"), role, pid, @@ -140,7 +140,6 @@ pub(crate) struct AccessStart { pub(crate) id: u64, pub(crate) timestamp_ms: u64, pub(crate) protocol: TrafficProtocol, - pub(crate) alpn: String, pub(crate) flow_id: Option, pub(crate) session_tag: Option, pub(crate) client: Option, @@ -156,7 +155,6 @@ pub(crate) struct AccessStarted { pub(crate) id: u64, pub(crate) timestamp_ms: u64, pub(crate) protocol: TrafficProtocol, - pub(crate) alpn: String, pub(crate) flow_id: Option, pub(crate) session_tag: Option, pub(crate) client: Option, @@ -174,7 +172,6 @@ pub(crate) struct AccessFinished { pub(crate) timestamp_ms: u64, pub(crate) duration_ms: u64, pub(crate) protocol: TrafficProtocol, - pub(crate) alpn: String, pub(crate) flow_id: Option, pub(crate) session_tag: Option, pub(crate) client: Option, diff --git a/src/tests/common/alpn.rs b/src/tests/common/alpn.rs index bc0f9ff..675cbe0 100644 --- a/src/tests/common/alpn.rs +++ b/src/tests/common/alpn.rs @@ -3,18 +3,6 @@ use super::*; -#[test] -fn alpn_defaults_to_now_one_and_preserves_custom_values() { - assert_eq!(parse_alpn(None).unwrap(), DEFAULT_ALPN); - assert_eq!(parse_alpn(Some("private/7")).unwrap(), "private/7"); -} - -#[test] -fn alpn_rejects_empty_and_oversized_values() { - assert!(parse_alpn(Some("")).is_err()); - assert!(parse_alpn(Some(&"a".repeat(256))).is_err()); -} - #[test] fn mux_marker_cannot_start_a_dedicated_flow_header() { assert!(crate::protocol::decode_flow_header(&[MUX_MARKER, 0, 0, 0, 1]).is_err()); diff --git a/src/tests/common/config.rs b/src/tests/common/config.rs index a261f99..b457490 100644 --- a/src/tests/common/config.rs +++ b/src/tests/common/config.rs @@ -8,36 +8,36 @@ use url::Url; #[test] fn query_first_ignores_unknown_parameters_and_keeps_first_duplicate() { - let parsed = Url::parse("portal://key@localhost:2077?log=debug&label=now%2F1").unwrap(); + let parsed = Url::parse("portal://key@localhost:2000?log=debug&label=now%2F1").unwrap(); let values = query_first(&parsed, &["log", "label"]).unwrap(); assert_eq!(values["log"], "debug"); assert_eq!(values["label"], "now/1"); - let duplicate = Url::parse("portal://key@localhost:2077?log=debug&log=event").unwrap(); + let duplicate = Url::parse("portal://key@localhost:2000?log=debug&log=event").unwrap(); assert_eq!(query_first(&duplicate, &["log"]).unwrap()["log"], "debug"); - let unknown = Url::parse("portal://key@localhost:2077?typo=value&%FF=value").unwrap(); + let unknown = Url::parse("portal://key@localhost:2000?typo=value&%FF=value").unwrap(); assert!(query_first(&unknown, &["log"]).unwrap().is_empty()); } #[test] fn query_first_preserves_literal_slash_and_plus_and_validates_the_selected_value() { - let protocol = Url::parse("portal://key@localhost:2077?label=private/2").unwrap(); + let protocol = Url::parse("portal://key@localhost:2000?label=private/2").unwrap(); assert_eq!( query_first(&protocol, &["label"]).unwrap()["label"], "private/2" ); - let parsed = Url::parse("portal://key@localhost:2077?label=now+private").unwrap(); + let parsed = Url::parse("portal://key@localhost:2000?label=now+private").unwrap(); assert_eq!( query_first(&parsed, &["label"]).unwrap()["label"], "now+private" ); - let bad = Url::parse("portal://key@localhost:2077?label=%GG").unwrap(); + let bad = Url::parse("portal://key@localhost:2000?label=%GG").unwrap(); assert!(query_first(&bad, &["label"]).is_err()); let ignored_bad_duplicate = - Url::parse("portal://key@localhost:2077?label=now%2F1&label=%GG").unwrap(); + Url::parse("portal://key@localhost:2000?label=now%2F1&label=%GG").unwrap(); assert_eq!( query_first(&ignored_bad_duplicate, &["label"]).unwrap()["label"], "now/1" diff --git a/src/tests/common/endpoint.rs b/src/tests/common/endpoint.rs new file mode 100644 index 0000000..74bd1cd --- /dev/null +++ b/src/tests/common/endpoint.rs @@ -0,0 +1,157 @@ +use super::*; + +fn parse(raw: &str, wildcard: bool) -> Result { + ServiceEndpoint::parse(&Url::parse(raw)?, wildcard, "test") +} + +#[test] +fn parses_compact_and_explicit_endpoints() { + let compact = parse("portal://key@*:2000", true).unwrap(); + assert_eq!(compact.canonical(), "*:2000"); + assert_eq!(compact.tcp.unwrap().port, 2000); + assert_eq!(compact.udp.unwrap().port, 2000); + + let explicit = parse("portal://key@*/udp6:2017/tcp4:2006", true).unwrap(); + assert_eq!(explicit.canonical(), "*/tcp4:2006/udp6:2017"); + assert_eq!(explicit.tcp.unwrap().family, AddressFamily::V4); + assert_eq!(explicit.udp.unwrap().family, AddressFamily::V6); +} + +#[test] +fn accepts_every_declared_carrier_token() { + for (carrier, family, is_tcp) in [ + ("tcp", AddressFamily::Any, true), + ("tcp4", AddressFamily::V4, true), + ("tcp6", AddressFamily::V6, true), + ("udp", AddressFamily::Any, false), + ("udp4", AddressFamily::V4, false), + ("udp6", AddressFamily::V6, false), + ] { + let port = if is_tcp { 2006 } else { 2017 }; + let endpoint = parse(&format!("portal://key@*/{carrier}:{port}"), true).unwrap(); + let parsed = if is_tcp { endpoint.tcp } else { endpoint.udp }.unwrap(); + assert_eq!(parsed.family, family, "carrier={carrier}"); + assert_eq!(parsed.port, port, "carrier={carrier}"); + } +} + +#[test] +fn accepts_port_boundaries_and_canonicalizes_leading_zeroes() { + for (raw_port, expected_port) in [("1", 1), ("00001", 1), ("65535", 65535)] { + let endpoint = parse(&format!("portal://key@*/tcp:{raw_port}"), true).unwrap(); + assert_eq!(endpoint.tcp.unwrap().port, expected_port); + assert_eq!(endpoint.canonical(), format!("*/tcp:{expected_port}")); + } +} + +#[test] +fn rejects_invalid_carrier_names_and_port_representations() { + for carrier in ["", "TCP", "Udp", "quic", "tcp7", "tcp%20"] { + let raw = format!("portal://key@*/{carrier}:2000"); + let error = parse(&raw, true).unwrap_err().to_string(); + assert!( + error.contains("unknown carrier"), + "{raw} returned {error:?}" + ); + } + for port in [ + "", "0", "-1", "+1", "1.0", "1%20", "%201", "0x50", "65536", "999999", "12", + ] { + let raw = format!("portal://key@*/tcp:{port}"); + assert!(parse(&raw, true).is_err(), "accepted {raw}"); + } +} + +#[test] +fn rejects_every_duplicate_transport_pair() { + for (carriers, port) in [ + (["tcp", "tcp4", "tcp6"], 2006), + (["udp", "udp4", "udp6"], 2017), + ] { + for first in carriers { + for second in carriers { + let raw = format!("portal://key@*/{first}:{port}/{second}:{port}"); + let error = parse(&raw, true).unwrap_err().to_string(); + assert!( + error.contains("carrier is declared more than once"), + "{raw} returned {error:?}" + ); + } + } + } +} + +#[test] +fn formats_ipv6_and_single_carriers() { + assert_eq!( + parse("vector://key@[2001:db8::1]/udp:2017", false) + .unwrap() + .canonical(), + "[2001:db8::1]/udp:2017" + ); + assert_eq!( + parse("vector://key@example.com/tcp:2006", false) + .unwrap() + .canonical(), + "example.com/tcp:2006" + ); +} + +#[test] +fn rejects_invalid_grammar_and_family_mismatches() { + for (raw, expected) in [ + ("portal://key@*:2000/tcp:2006", "choose either HOST:PORT"), + ("portal://key@*/", "must not contain empty segments"), + ("portal://key@*/tcp:2006/", "trailing slash"), + ("portal://key@*/tcp:2006//udp:2017", "empty segments"), + ("portal://key@*/tcp", "must use CARRIER:PORT"), + ("portal://key@*/tcp:1:2", "decimal digits only"), + ("portal://key@*/tcp:abc", "decimal digits only"), + ("portal://key@*/tcp:-1", "decimal digits only"), + ("portal://key@*/tcp:+2006", "decimal digits only"), + ("portal://key@*/tcp:", "decimal digits only"), + ("portal://key@*/tcp:0", "1..=65535"), + ("portal://key@*/tcp:65536", "1..=65535"), + ("portal://key@*/TCP:2006", "unknown carrier"), + ("portal://key@*/sctp:2000", "unknown carrier"), + ( + "portal://key@*/tcp:2006/tcp6:2006", + "TCP carrier is declared more than once", + ), + ( + "portal://key@*/udp4:2017/udp:2017", + "UDP carrier is declared more than once", + ), + ( + "portal://key@192.0.2.1/tcp6:2006", + "address family does not match", + ), + ( + "portal://key@[2001:db8::1]/udp4:2017", + "address family does not match", + ), + ] { + let error = parse(raw, true).unwrap_err().to_string(); + assert!(error.contains(expected), "{raw} returned {error:?}"); + } + let error = parse("vector://key@*:2000", false).unwrap_err().to_string(); + assert!(error.contains("wildcard host is only valid for Portal listeners")); +} + +#[test] +fn raw_input_validation_rejects_dot_segments_before_url_normalization() { + for raw in [ + "portal://key@*/tcp:2006/../udp:2017", + "portal://key@*/tcp:2006/./udp:2017", + "portal://key@*/tcp:2006/%2e%2e/udp:2017", + "vector://key@example.com/%2E./tcp:2006?socks=:1080", + ] { + let error = validate_endpoint_url_input(raw, "test") + .unwrap_err() + .to_string(); + assert!( + error.contains("must not contain '.' or '..' segments"), + "{raw}: {error}" + ); + } +} diff --git a/src/tests/common/network.rs b/src/tests/common/network.rs index 8e69feb..7be46f1 100644 --- a/src/tests/common/network.rs +++ b/src/tests/common/network.rs @@ -57,3 +57,49 @@ fn filter_addrs_matches_local_ip_family() { [addrs[1]] ); } + +#[test] +fn carrier_bind_resolution_expands_wildcards_and_filters_dns() { + let v4 = CarrierEndpoint { + port: 8080, + family: AddressFamily::V4, + }; + let v6 = CarrierEndpoint { + port: 9090, + family: AddressFamily::V6, + }; + assert_eq!( + resolve_bind_addrs("*", v4).unwrap(), + [SocketAddr::from(([0, 0, 0, 0], 8080))] + ); + assert_eq!( + resolve_bind_addrs("*", v6).unwrap(), + [SocketAddr::from(([0u16; 8], 9090))] + ); + assert!(resolve_bind_addrs("127.0.0.1", v6).is_err()); + + let localhost = resolve_bind_addrs("localhost", v4).unwrap(); + assert!(!localhost.is_empty()); + assert!(localhost.iter().all(SocketAddr::is_ipv4)); + assert!(localhost.windows(2).all(|pair| pair[0] != pair[1])); +} + +#[test] +fn carrier_and_local_bind_family_filters_are_both_enforced() { + let addrs = [ + SocketAddr::from(([127, 0, 0, 1], 443)), + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 443)), + ]; + assert_eq!( + filter_addrs_for_family(addrs.into_iter(), None, AddressFamily::V6), + [addrs[1]] + ); + assert!( + filter_addrs_for_family( + addrs.into_iter(), + Some("127.0.0.1".parse().unwrap()), + AddressFamily::V6, + ) + .is_empty() + ); +} diff --git a/src/tests/common/socks.rs b/src/tests/common/socks.rs index 03822df..7e23fa5 100644 --- a/src/tests/common/socks.rs +++ b/src/tests/common/socks.rs @@ -25,19 +25,19 @@ fn parse(raw: &str) -> Result> { #[test] fn parses_disabled_and_endpoint_forms() { for raw in [ - "portal://secret@127.0.0.1:2077", - "portal://secret@127.0.0.1:2077?socks=", - "portal://secret@127.0.0.1:2077?socks=none", + "portal://secret@127.0.0.1:2000", + "portal://secret@127.0.0.1:2000?socks=", + "portal://secret@127.0.0.1:2000?socks=none", ] { assert!(parse(raw).unwrap().is_none()); } - let domain = parse("portal://secret@127.0.0.1:2077?socks=proxy.test:1080") + let domain = parse("portal://secret@127.0.0.1:2000?socks=proxy.test:1080") .unwrap() .unwrap(); assert_eq!(domain.endpoint(), "proxy.test:1080"); - let ipv6 = parse("portal://secret@127.0.0.1:2077?socks=[::1]:1080") + let ipv6 = parse("portal://secret@127.0.0.1:2000?socks=[::1]:1080") .unwrap() .unwrap(); assert_eq!(ipv6.endpoint(), "[::1]:1080"); @@ -46,7 +46,7 @@ fn parses_disabled_and_endpoint_forms() { #[test] fn parses_percent_encoded_credentials_without_exposing_them() { let config = - parse("portal://secret@127.0.0.1:2077?socks=user%3Aname:p%40ss%26word@proxy.test:1080") + parse("portal://secret@127.0.0.1:2000?socks=user%3Aname:p%40ss%26word@proxy.test:1080") .unwrap() .unwrap(); let credentials = config.credentials().unwrap(); @@ -61,14 +61,14 @@ fn parses_percent_encoded_credentials_without_exposing_them() { #[test] fn rejects_ambiguous_or_invalid_configuration() { for raw in [ - "portal://secret@127.0.0.1:2077?socks=user@proxy.test:1080", - "portal://secret@127.0.0.1:2077?socks=:pass@proxy.test:1080", - "portal://secret@127.0.0.1:2077?socks=user:@proxy.test:1080", - "portal://secret@127.0.0.1:2077?socks=user:p:ass@proxy.test:1080", - "portal://secret@127.0.0.1:2077?socks=user:p+ass@proxy.test:1080", - "portal://secret@127.0.0.1:2077?socks=proxy.test:0", - "portal://secret@127.0.0.1:2077?socks=::1:1080", - "portal://secret@127.0.0.1:2077?socks=user:%GG@proxy.test:1080", + "portal://secret@127.0.0.1:2000?socks=user@proxy.test:1080", + "portal://secret@127.0.0.1:2000?socks=:pass@proxy.test:1080", + "portal://secret@127.0.0.1:2000?socks=user:@proxy.test:1080", + "portal://secret@127.0.0.1:2000?socks=user:p:ass@proxy.test:1080", + "portal://secret@127.0.0.1:2000?socks=user:p+ass@proxy.test:1080", + "portal://secret@127.0.0.1:2000?socks=proxy.test:0", + "portal://secret@127.0.0.1:2000?socks=::1:1080", + "portal://secret@127.0.0.1:2000?socks=user:%GG@proxy.test:1080", ] { assert!(parse(raw).is_err(), "accepted {raw}"); } @@ -77,7 +77,7 @@ fn rejects_ambiguous_or_invalid_configuration() { #[test] fn duplicate_socks_uses_the_first_value() { let config = - parse("portal://secret@127.0.0.1:2077?socks=proxy.test:1080&socks=other.test:1080") + parse("portal://secret@127.0.0.1:2000?socks=proxy.test:1080&socks=other.test:1080") .unwrap() .unwrap(); @@ -86,7 +86,7 @@ fn duplicate_socks_uses_the_first_value() { #[test] fn malformed_unknown_query_key_is_ignored() { - let config = parse("portal://secret@127.0.0.1:2077?%FF=x&socks=proxy.test:1080") + let config = parse("portal://secret@127.0.0.1:2000?%FF=x&socks=proxy.test:1080") .unwrap() .unwrap(); @@ -98,11 +98,11 @@ fn credential_lengths_follow_rfc_1929() { let username = "u".repeat(255); let password = "p".repeat(255); let accepted = - format!("portal://secret@127.0.0.1:2077?socks={username}:{password}@proxy.test:1080"); + format!("portal://secret@127.0.0.1:2000?socks={username}:{password}@proxy.test:1080"); assert!(parse(&accepted).is_ok()); let username = "u".repeat(256); - let rejected = format!("portal://secret@127.0.0.1:2077?socks={username}:p@proxy.test:1080"); + let rejected = format!("portal://secret@127.0.0.1:2000?socks={username}:p@proxy.test:1080"); assert!(parse(&rejected).is_err()); } @@ -124,7 +124,7 @@ async fn tcp_connect_uses_only_no_auth_and_preserves_domain() { stream.write_all(&payload).await.unwrap(); }); - let config = parse(&format!("portal://secret@127.0.0.1:2077?socks={endpoint}")).unwrap(); + let config = parse(&format!("portal://secret@127.0.0.1:2000?socks={endpoint}")).unwrap(); let dialer = OutboundDialer::new("auto".to_string(), config); let target = Target::domain("target.test", 443).unwrap(); let mut stream = dialer @@ -154,7 +154,7 @@ async fn authenticated_connect_cannot_downgrade_to_no_auth() { }); let config = parse(&format!( - "portal://secret@127.0.0.1:2077?socks=user:pass@{endpoint}" + "portal://secret@127.0.0.1:2000?socks=user:pass@{endpoint}" )) .unwrap(); let dialer = OutboundDialer::new("auto".to_string(), config); @@ -210,7 +210,7 @@ async fn udp_associate_wraps_payload_and_keeps_control_alive() { assert_eq!(control.read(&mut eof).await.unwrap(), 0); }); - let config = parse(&format!("portal://secret@127.0.0.1:2077?socks={endpoint}")).unwrap(); + let config = parse(&format!("portal://secret@127.0.0.1:2000?socks={endpoint}")).unwrap(); let dialer = OutboundDialer::new("127.0.0.1".to_string(), config); let target = Target::domain("dns.test", 53).unwrap(); let socket = dialer @@ -252,7 +252,7 @@ async fn proxy_failure_never_falls_back_to_direct_target() { }); let config = parse(&format!( - "portal://secret@127.0.0.1:2077?socks={proxy_addr}" + "portal://secret@127.0.0.1:2000?socks={proxy_addr}" )) .unwrap(); let dialer = OutboundDialer::new("auto".to_string(), config); @@ -289,7 +289,7 @@ async fn udp_association_ends_when_control_connection_closes() { write_test_reply(&mut control, relay_addr).await; }); - let config = parse(&format!("portal://secret@127.0.0.1:2077?socks={endpoint}")).unwrap(); + let config = parse(&format!("portal://secret@127.0.0.1:2000?socks={endpoint}")).unwrap(); let dialer = OutboundDialer::new("auto".to_string(), config); let target = Target::domain("dns.test", 53).unwrap(); let socket = dialer @@ -341,7 +341,7 @@ async fn each_udp_flow_uses_a_distinct_association() { drop(relays); }); - let config = parse(&format!("portal://secret@127.0.0.1:2077?socks={endpoint}")).unwrap(); + let config = parse(&format!("portal://secret@127.0.0.1:2000?socks={endpoint}")).unwrap(); let dialer = OutboundDialer::new("auto".to_string(), config); let first_target = Target::domain("one.test", 53).unwrap(); let first = dialer diff --git a/src/tests/common/tls.rs b/src/tests/common/tls.rs index 04d9291..c7cc32b 100644 --- a/src/tests/common/tls.rs +++ b/src/tests/common/tls.rs @@ -18,8 +18,7 @@ fn certificate_sha256_uses_lowercase_hex() { #[test] fn server_tls_config_explicitly_disables_early_data() { let (_, tls, _quic) = new_server_configs( - &Url::parse("portal://secret@127.0.0.1:2077?tls=1").unwrap(), - "now/1", + &Url::parse("portal://secret@127.0.0.1:2000?tls=1").unwrap(), Logger::new(LogLevel::None, false), ) .unwrap(); @@ -29,13 +28,12 @@ fn server_tls_config_explicitly_disables_early_data() { } #[test] -fn server_offers_only_the_configured_alpn() { +fn server_offers_only_nw2() { let (_, tls, _) = new_server_configs( - &Url::parse("portal://secret@127.0.0.1:2077?tls=1&alpn=private/2&pool=8").unwrap(), - "private/2", + &Url::parse("portal://secret@127.0.0.1:2000?tls=1&alpn=private/2&pool=8").unwrap(), Logger::new(LogLevel::None, false), ) .unwrap(); - assert_eq!(tls.alpn_protocols, [b"private/2".to_vec()]); + assert_eq!(tls.alpn_protocols, [b"nw2".to_vec()]); } diff --git a/src/tests/main.rs b/src/tests/main.rs index 3f2d596..f758531 100644 --- a/src/tests/main.rs +++ b/src/tests/main.rs @@ -14,27 +14,27 @@ fn help_text_documents_usage_and_configuration_surface() { "-h, --help", "-v, --version", "portal://@:", + ":", "vector://@:", "tls=1|2", - "net=mix|tcp|udp", + "tcp4, udp4", + "tcp6, udp6", "socks=", "next=", "sni=", "pin=", - "alpn=", "mux=0|1", "up=tcp|udp|mix", "down=tcp|udp|mix", + "Defaults to the only declared carrier, or TCP.", + "with both, TCP is the", "Use TLS Mux when the native route can select TCP.", "UDP ASSOCIATE", "rate=", "etar=", "UDP-over-TCP (UoT)", - "NOW_MAX_TCP_FLOWS", - "NOW_MAX_UDP_FLOWS", "NOW_QUIC_UDP_QUEUE_BYTES", - "NOW_QUIC_MEMORY_PROFILE", - "NOW_MAX_PENDING_PAIRS", + "NOW_TRANSPORT_MEMORY_PROFILE", "NOW_FLOW_PAIR_TIMEOUT", "NOW_FLOW_SETUP_TIMEOUT", "NOW_MIX_FALLBACK_TIMEOUT", @@ -50,7 +50,14 @@ fn help_text_documents_usage_and_configuration_surface() { "missing help text: {expected}" ); } - for removed in ["pool=", "NOW_QUIC_MAX_UDP_FLOWS"] { + for removed in [ + "NOW_MAX_TCP_FLOWS", + "NOW_MAX_UDP_FLOWS", + "NOW_MAX_PENDING_PAIRS", + "alpn=", + "pool=", + "NOW_QUIC_MAX_UDP_FLOWS", + ] { assert!( !HELP_TEXT.contains(removed), "removed help option: {removed}" @@ -61,13 +68,12 @@ fn help_text_documents_usage_and_configuration_surface() { #[test] fn parse_command_url_keeps_vector_remote_host() { let parsed = parse_command_url( - "vector://secret@relay.example:2077?up=udp&down=tcp&socks=127.0.0.1:1080", + "vector://secret@relay.example:2000?up=udp&down=tcp&socks=127.0.0.1:1080", ) .unwrap(); - assert_eq!(parsed.url.scheme(), "vector"); - assert_eq!(parsed.url.host_str(), Some("relay.example")); - assert_eq!(parsed.url.port(), Some(2077)); - assert_eq!(parsed.listen_host, None); + assert_eq!(parsed.scheme(), "vector"); + assert_eq!(parsed.host_str(), Some("relay.example")); + assert_eq!(parsed.port(), Some(2000)); } #[test] @@ -79,16 +85,15 @@ fn logger_rejects_unknown_or_empty_levels() { } #[test] -fn parse_command_url_accepts_empty_listen_host() { - let parsed = parse_command_url("portal://secret@:2077?log=none&dial=::1").unwrap(); +fn parse_command_url_normalizes_legacy_empty_listen_host() { + let parsed = parse_command_url("portal://secret@:2000?log=none&dial=::1").unwrap(); - assert_eq!(parsed.url.scheme(), "portal"); - assert_eq!(parsed.url.username(), "secret"); - assert_eq!(parsed.url.port(), Some(2077)); - assert_eq!(parsed.listen_host.as_deref(), Some("")); + assert_eq!(parsed.scheme(), "portal"); + assert_eq!(parsed.username(), "secret"); + assert_eq!(parsed.host_str(), Some("*")); + assert_eq!(parsed.port(), Some(2000)); assert_eq!( parsed - .url .query_pairs() .find(|(key, _)| key == "dial") .map(|(_, value)| value.into_owned()) @@ -98,20 +103,137 @@ fn parse_command_url_accepts_empty_listen_host() { } #[test] -fn parse_command_url_accepts_empty_listen_host_without_userinfo() { - let parsed = parse_command_url("portal://:2077").unwrap(); +fn parse_command_url_normalizes_legacy_empty_host_without_userinfo() { + let parsed = parse_command_url("portal://:2000").unwrap(); - assert_eq!(parsed.url.scheme(), "portal"); - assert_eq!(parsed.url.username(), ""); - assert_eq!(parsed.url.port(), Some(2077)); - assert_eq!(parsed.listen_host.as_deref(), Some("")); + assert_eq!(parsed.scheme(), "portal"); + assert_eq!(parsed.username(), ""); + assert_eq!(parsed.host_str(), Some("*")); + assert_eq!(parsed.port(), Some(2000)); +} + +#[test] +fn parse_command_url_rejects_empty_host_for_explicit_carriers() { + assert!(parse_command_url("portal://secret@/tcp:2006").is_err()); } #[test] fn parse_command_url_keeps_normal_hosts() { - let parsed = parse_command_url("portal://secret@[::]:2077?dial=auto").unwrap(); + let parsed = parse_command_url("portal://secret@[::]:2000?dial=auto").unwrap(); + + assert_eq!(parsed.host_str(), Some("[::]")); + assert_eq!(parsed.port(), Some(2000)); +} - assert_eq!(parsed.url.host_str(), Some("[::]")); - assert_eq!(parsed.url.port(), Some(2077)); - assert_eq!(parsed.listen_host, None); +#[tokio::test] +async fn invalid_configuration_urls_fail_before_service_startup_with_safe_errors() { + const SECRET: &str = "do-not-print-this-secret"; + for (raw, expected) in [ + ("not-a-url".to_owned(), "invalid configuration URL"), + ( + format!("ftp://{SECRET}@example.com:2000"), + "scheme must be portal or vector", + ), + ("portal://@*:2000?log=none".to_owned(), "missing shared key"), + ( + format!("portal://{SECRET}@*:abc?log=none"), + "invalid port number", + ), + ( + format!("portal://{SECRET}@*:0?log=none"), + "compact endpoint requires a port in 1..=65535", + ), + ( + format!("portal://{SECRET}@/tcp:2006?log=none"), + "empty host", + ), + ( + format!("portal://{SECRET}@*/tcp?log=none"), + "must use CARRIER:PORT", + ), + ( + format!("portal://{SECRET}@*/tcp:2006/../udp:2017?log=none"), + "must not contain '.' or '..' segments", + ), + ( + format!("portal://{SECRET}@*:2000/tcp:2006?log=none"), + "choose either HOST:PORT", + ), + ( + format!("portal://{SECRET}@*/udp:2017/udp6:2017?log=none"), + "UDP carrier is declared more than once", + ), + ( + format!("portal://{SECRET}@*/tcp:65536?log=none"), + "carrier port must be in 1..=65535", + ), + ( + format!("portal://{SECRET}@192.0.2.1/tcp6:2006?log=none"), + "address family does not match", + ), + ( + format!("portal://{SECRET}@*:2000?log=verbose"), + "log must be none, debug, info, warn, error, or event", + ), + ( + format!("portal://{SECRET}@*:2000?rate=-1&log=none"), + "rate must be a non-negative integer", + ), + ( + format!("portal://{SECRET}@*:2000?socks=bad&log=none"), + "invalid socks endpoint: expected HOST:PORT", + ), + ( + format!("portal://{SECRET}@unresolvable.invalid:2000?rate=-1&log=none"), + "rate must be a non-negative integer", + ), + ( + format!("vector://{SECRET}@*/tcp:2006?socks=:1080&log=none"), + "wildcard host is only valid for Portal listeners", + ), + ( + "vector://@example.com:2000?socks=:1080&log=none".to_owned(), + "missing shared key", + ), + ( + format!("vector://{SECRET}@example.com?socks=:1080&log=none"), + "compact endpoint requires a port in 1..=65535", + ), + ( + format!("vector://{SECRET}@example.com:2000?log=none"), + "socks parameter is required", + ), + ( + format!("vector://{SECRET}@example.com/udp:2017?up=tcp&socks=:1080&log=none"), + "up selects a carrier not declared by the endpoint", + ), + ( + format!( + "portal://outer@*:2000?next={SECRET}@origin.example/tcp:2006/../udp:2017&log=none" + ), + "must not contain '.' or '..' segments", + ), + ( + format!("portal://outer@*:2000?next={SECRET}@*/tcp:2006&log=none"), + "wildcard host is only valid for Portal listeners", + ), + ( + format!("portal://outer@*:2000?next={SECRET}@origin.example/tcp:2006?inner=1&log=none"), + "expected shared-key and one endpoint", + ), + ] { + let args = vec!["nowhere".to_owned(), raw]; + let error = tokio::time::timeout(std::time::Duration::from_secs(1), start(args)) + .await + .expect("invalid configuration attempted to run a service") + .unwrap_err(); + let message = format_start_error(&error); + assert!(message.starts_with("error: ")); + assert!(message.contains(expected), "response was {message:?}"); + assert!( + !message.contains("::"), + "response exposed internal names: {message:?}" + ); + assert!(!message.contains(SECRET), "response leaked the shared key"); + } } diff --git a/src/tests/mux/runtime.rs b/src/tests/mux/runtime.rs index 69b47cf..961d7b5 100644 --- a/src/tests/mux/runtime.rs +++ b/src/tests/mux/runtime.rs @@ -1,6 +1,281 @@ +use super::wire::{CLOSE_FIN, CLOSE_RESET, FrameHeader, encode_header}; use super::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +#[tokio::test] +async fn more_than_256_live_streams_transfer_and_half_close() { + tokio::time::timeout(Duration::from_secs(10), async { + let (left, right) = tokio::io::duplex(1 << 20); + let (client, _) = MuxHandle::start(left, MuxConfig::default()).unwrap(); + let (server, mut incoming) = MuxHandle::start(right, MuxConfig::default()).unwrap(); + let mut streams = Vec::new(); + for id in 1..=1024 { + let outgoing = client.open_stream(id).await.unwrap(); + let accepted = incoming.accept().await.unwrap().unwrap(); + streams.push((outgoing, accepted)); + } + assert_eq!(client.active_streams(), 1024); + for (mut outgoing, mut accepted) in streams { + outgoing.write_all(b"ok").await.unwrap(); + drop(outgoing); + let mut bytes = Vec::new(); + accepted.read_to_end(&mut bytes).await.unwrap(); + assert_eq!(bytes, b"ok"); + } + assert_eq!(client.active_streams(), 0); + client.close(); + server.close(); + }) + .await + .expect("stream admission must not depend on terminal queue capacity"); +} + +#[tokio::test] +async fn open_reset_churn_cannot_overflow_pending_incoming_admission() { + let config = MuxConfig { + active_stream_limit: 2, + ..MuxConfig::default() + }; + let (mut peer, carrier) = tokio::io::duplex(4096); + let (server, incoming) = MuxHandle::start(carrier, config).unwrap(); + for flow_id in 1..=3 { + peer.write_all(&encode_header(FrameHeader::open(flow_id, 0).unwrap()).unwrap()) + .await + .unwrap(); + peer.write_all(&encode_header(FrameHeader::close(flow_id, CLOSE_RESET).unwrap()).unwrap()) + .await + .unwrap(); + } + tokio::time::timeout(Duration::from_secs(1), server.closed()) + .await + .expect("RESET must not bypass pending OPEN admission"); + assert_eq!(incoming.receiver.len(), 2); + assert_eq!(server.active_streams(), 0); +} + +#[tokio::test] +async fn dropped_writers_close_carrier_when_terminal_delivery_is_saturated() { + let config = MuxConfig { + active_stream_limit: 2, + ..MuxConfig::default() + }; + let (_peer, carrier) = tokio::io::duplex(1); + let (handle, _incoming) = MuxHandle::start(carrier, config).unwrap(); + + // No await gives the terminal dispatcher no opportunity to drain between + // drops. Flow state is released each time, so only the terminal queue can + // bound this churn. + for flow_id in 1..=3 { + drop(handle.prepare_stream(flow_id).unwrap()); + } + + assert!(handle.is_closed()); + assert_eq!(handle.active_streams(), 0); +} + +#[tokio::test] +async fn remote_open_admission_closes_carrier_at_the_metadata_limit() { + let config = MuxConfig { + active_stream_limit: 2, + ..MuxConfig::default() + }; + let (mut peer, carrier) = tokio::io::duplex(4096); + let (server, mut incoming) = MuxHandle::start(carrier, config).unwrap(); + let mut streams = Vec::new(); + + for flow_id in 1..=2 { + peer.write_all(&encode_header(FrameHeader::open(flow_id, 0).unwrap()).unwrap()) + .await + .unwrap(); + let stream = incoming.accept().await.unwrap().unwrap(); + assert_eq!(stream.flow_id(), flow_id); + streams.push(stream); + } + assert_eq!(server.active_streams(), 2); + + peer.write_all(&encode_header(FrameHeader::open(3, 0).unwrap()).unwrap()) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), server.closed()) + .await + .expect("OPEN beyond the metadata budget must close the carrier"); + assert_eq!(server.active_streams(), 0); +} + +#[tokio::test] +async fn slow_small_packet_reader_does_not_block_other_flows() { + tokio::time::timeout(Duration::from_secs(5), async { + let (left, right) = tokio::io::duplex(1 << 20); + let (client, _) = MuxHandle::start(left, MuxConfig::default()).unwrap(); + let (server, mut incoming) = MuxHandle::start(right, MuxConfig::default()).unwrap(); + let mut slow = client.open_stream(1).await.unwrap(); + let _slow_peer = incoming.accept().await.unwrap().unwrap(); + for _ in 0..1024 { + slow.write_all(b"x").await.unwrap(); + } + let mut fast = client.open_stream(2).await.unwrap(); + let mut fast_peer = incoming.accept().await.unwrap().unwrap(); + fast.write_all(b"ok").await.unwrap(); + let mut bytes = [0; 2]; + fast_peer.read_exact(&mut bytes).await.unwrap(); + assert_eq!(&bytes, b"ok"); + client.close(); + server.close(); + }) + .await + .expect("per-flow queue must not stall the carrier reader"); +} + +#[test] +fn production_idle_timeout_remains_thirty_seconds() { + assert_eq!(MUX_IDLE_TIMEOUT, Duration::from_secs(30)); +} + +#[tokio::test] +async fn abandoned_reader_returns_credit_without_closing_other_streams() { + tokio::time::timeout(Duration::from_secs(5), async { + let (left, right) = tokio::io::duplex(1 << 20); + let (client, _) = MuxHandle::start(left, MuxConfig::default()).unwrap(); + let (server, mut incoming) = MuxHandle::start(right, MuxConfig::default()).unwrap(); + let outgoing = client.open_stream(1).await.unwrap(); + let mut accepted = incoming.accept().await.unwrap().unwrap(); + let (reader, _writer) = outgoing.into_split(); + drop(reader); + accepted + .write_all(&vec![0; 2 * MAX_CONNECTION_WINDOW_BYTES]) + .await + .unwrap(); + let mut other = client.open_stream(2).await.unwrap(); + let mut peer = incoming.accept().await.unwrap().unwrap(); + other.write_all(b"ok").await.unwrap(); + let mut bytes = [0; 2]; + peer.read_exact(&mut bytes).await.unwrap(); + assert_eq!(&bytes, b"ok"); + assert!(!client.is_closed()); + client.close(); + server.close(); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn a_blocked_flow_cannot_fill_the_shared_send_queue() { + let (left, _right) = tokio::io::duplex(1); + let (handle, _incoming) = MuxHandle::start(left, MuxConfig::default()).unwrap(); + let mut a = handle.open_stream(1).await.unwrap(); + let mut b = handle.open_stream(2).await.unwrap(); + a.write_all(b"queued").await.unwrap(); + let pending = tokio::spawn(async move { a.write_all(b"blocked").await }); + tokio::time::timeout(Duration::from_secs(1), b.write_all(b"other")) + .await + .unwrap() + .unwrap(); + tokio::task::yield_now().await; + assert!(!pending.is_finished()); + handle.close(); + assert!( + tokio::time::timeout(Duration::from_secs(1), pending) + .await + .unwrap() + .unwrap() + .is_err() + ); +} + +async fn assert_raw_frame_closes_carrier(frame: &[u8]) { + let (left, mut peer) = tokio::io::duplex(1 << 20); + let (handle, _incoming) = MuxHandle::start(left, MuxConfig::default()).unwrap(); + peer.write_all(frame).await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), handle.closed()) + .await + .expect("invalid frame must close carrier"); +} + +#[tokio::test] +async fn invalid_kind_and_unknown_flow_data_close_carrier() { + assert_raw_frame_closes_carrier(&[0xff, 0, 0, 0, 0, 0, 1]).await; + + let mut frame = encode_header(FrameHeader::data(99, 1).unwrap()) + .unwrap() + .to_vec(); + frame.push(0); + assert_raw_frame_closes_carrier(&frame).await; +} + +#[tokio::test] +async fn invalid_prepared_id_does_not_reserve_a_stream_or_close_the_carrier() { + let (left, _peer) = tokio::io::duplex(1024); + let (handle, _incoming) = MuxHandle::start(left, MuxConfig::default()).unwrap(); + for id in [0, crate::protocol::MAX_FLOW_ID + 1, u32::MAX] { + assert!(handle.prepare_stream(id).is_err()); + assert_eq!(handle.active_streams(), 0); + assert!(!handle.is_closed()); + } + let _stream = handle.prepare_stream(crate::protocol::MAX_FLOW_ID).unwrap(); + assert_eq!(handle.active_streams(), 1); + handle.close(); +} + +#[tokio::test] +async fn duplicate_open_and_credit_overflow_close_carrier() { + let open = encode_header(FrameHeader::open(7, 0).unwrap()).unwrap(); + let mut duplicate = open.to_vec(); + duplicate.extend_from_slice(&open); + assert_raw_frame_closes_carrier(&duplicate).await; + + let overflow = encode_header(FrameHeader::window(0, u16::MAX as usize).unwrap()).unwrap(); + assert_raw_frame_closes_carrier(&overflow).await; +} + +#[tokio::test] +async fn duplicate_close_and_late_stream_window_are_idempotent() { + let (left, mut peer) = tokio::io::duplex(1 << 20); + let (handle, mut incoming) = MuxHandle::start(left, MuxConfig::default()).unwrap(); + peer.write_all(&encode_header(FrameHeader::open(7, 0).unwrap()).unwrap()) + .await + .unwrap(); + let stream = incoming.accept().await.unwrap().unwrap(); + let reset = encode_header(FrameHeader::close(7, CLOSE_RESET).unwrap()).unwrap(); + peer.write_all(&reset).await.unwrap(); + peer.write_all(&reset).await.unwrap(); + peer.write_all(&encode_header(FrameHeader::window(7, 1).unwrap()).unwrap()) + .await + .unwrap(); + peer.write_all(&encode_header(FrameHeader::open(9, 0).unwrap()).unwrap()) + .await + .unwrap(); + let next = tokio::time::timeout(Duration::from_secs(1), incoming.accept()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(next.flow_id(), 9); + assert!(!handle.is_closed()); + drop(stream); +} + +#[tokio::test] +async fn data_after_fin_closes_carrier() { + let (left, mut peer) = tokio::io::duplex(1 << 20); + let (handle, mut incoming) = MuxHandle::start(left, MuxConfig::default()).unwrap(); + peer.write_all(&encode_header(FrameHeader::open(8, 0).unwrap()).unwrap()) + .await + .unwrap(); + let _stream = incoming.accept().await.unwrap().unwrap(); + peer.write_all(&encode_header(FrameHeader::close(8, CLOSE_FIN).unwrap()).unwrap()) + .await + .unwrap(); + let mut data = encode_header(FrameHeader::data(8, 1).unwrap()) + .unwrap() + .to_vec(); + data.push(0); + peer.write_all(&data).await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), handle.closed()) + .await + .expect("DATA after FIN must close carrier"); +} + #[tokio::test] async fn idle_deadline_resets_when_a_stream_becomes_active() { let (left, right) = tokio::io::duplex(1 << 20); @@ -69,6 +344,42 @@ async fn many_small_writes_cross_the_credit_window() { assert!(received.iter().all(|byte| *byte == 0x5a)); } +#[tokio::test] +async fn peers_with_different_profiles_exchange_beyond_the_base_window() { + let memory = MuxConfig { + stream_window_bytes: 4 * MIB, + connection_window_bytes: 8 * MIB, + ..MuxConfig::default() + }; + let throughput = MuxConfig { + stream_window_bytes: 16 * MIB, + connection_window_bytes: 32 * MIB, + ..MuxConfig::default() + }; + let (left, right) = tokio::io::duplex(1 << 20); + let (client, _) = MuxHandle::start(left, memory).unwrap(); + let (_server, mut incoming) = MuxHandle::start(right, throughput).unwrap(); + let mut outgoing = client.open_stream(81).await.unwrap(); + let mut accepted = incoming.accept().await.unwrap().unwrap(); + let sender = tokio::spawn(async move { + let payload = vec![0x81; 12 * MIB]; + outgoing.write_all(&payload).await.unwrap(); + outgoing.shutdown().await.unwrap(); + }); + let mut received = 0; + let mut buffer = vec![0; FRAME_BYTES]; + loop { + let count = accepted.read(&mut buffer).await.unwrap(); + if count == 0 { + break; + } + assert!(buffer[..count].iter().all(|byte| *byte == 0x81)); + received += count; + } + sender.await.unwrap(); + assert_eq!(received, 12 * MIB); +} + #[tokio::test] async fn carrier_close_fails_every_flow() { let (left, right) = tokio::io::duplex(1024); @@ -127,19 +438,37 @@ async fn dropping_unused_writer_preserves_incoming_half() { } #[tokio::test] -async fn fair_credit_tracks_active_stream_count() { +async fn stream_credit_does_not_shrink_with_active_stream_count() { let (left, right) = tokio::io::duplex(1 << 20); let (client, _) = MuxHandle::start(left, MuxConfig::default()).unwrap(); let (_server, mut incoming) = MuxHandle::start(right, MuxConfig::default()).unwrap(); let mut streams = Vec::new(); + let mut peers = Vec::new(); for flow_id in 1..=128 { streams.push(client.open_stream(flow_id).await.unwrap()); - let _ = incoming.accept().await.unwrap().unwrap(); + peers.push(incoming.accept().await.unwrap().unwrap()); } + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let all_ready = client.shared.flows.lock().unwrap().values().all(|flow| { + flow.send_credit.available_permits() + == credit_units(MuxConfig::default().stream_window_bytes) + }); + if all_ready { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); { let flows = client.shared.flows.lock().unwrap(); - assert!(flows.values().all(|flow| flow.fair_limit == 256 * 1024)); + assert!(flows.values().all(|flow| { + flow.send_credit.available_permits() + == credit_units(MuxConfig::default().stream_window_bytes) + })); } let retained = streams.pop().unwrap(); @@ -148,9 +477,124 @@ async fn fair_credit_tracks_active_stream_count() { let flows = client.shared.flows.lock().unwrap(); assert_eq!(flows.len(), 1); assert_eq!( - flows.values().next().unwrap().fair_limit, - MuxConfig::default().stream_window_bytes + flows + .values() + .next() + .unwrap() + .send_credit + .available_permits(), + credit_units(MuxConfig::default().stream_window_bytes) ); } drop(retained); + drop(peers); +} + +#[tokio::test] +async fn concurrent_streams_cross_the_connection_window() { + const FLOWS: u32 = 16; + const BYTES_PER_FLOW: usize = 3 * MIB; + let (left, right) = tokio::io::duplex(1 << 20); + let (client, _) = MuxHandle::start(left, MuxConfig::default()).unwrap(); + let (_server, mut incoming) = MuxHandle::start(right, MuxConfig::default()).unwrap(); + let mut writers = Vec::new(); + let mut readers = Vec::new(); + for flow_id in 1..=FLOWS { + writers.push(client.open_stream(flow_id).await.unwrap()); + readers.push(incoming.accept().await.unwrap().unwrap()); + } + + let mut tasks = writers + .into_iter() + .map(|mut stream| { + tokio::spawn(async move { + stream.write_all(&vec![0x5a; BYTES_PER_FLOW]).await.unwrap(); + stream.shutdown().await.unwrap(); + }) + }) + .collect::>(); + tasks.extend(readers.into_iter().map(|mut stream| { + tokio::spawn(async move { + let mut received = Vec::new(); + stream.read_to_end(&mut received).await.unwrap(); + assert_eq!(received.len(), BYTES_PER_FLOW); + assert!(received.iter().all(|byte| *byte == 0x5a)); + }) + })); + for task in tasks { + task.await.unwrap(); + } +} + +#[tokio::test] +async fn closing_carrier_wakes_exhausted_stream_credit() { + let (left, right) = tokio::io::duplex(1024); + let (handle, _) = MuxHandle::start(left, MuxConfig::default()).unwrap(); + let mut stream = handle.open_stream(501).await.unwrap(); + let credit = handle.shared.send_credit(501).unwrap(); + let permits = credit.available_permits(); + credit + .clone() + .acquire_many_owned(permits as u32) + .await + .unwrap() + .forget(); + let write = tokio::spawn(async move { stream.write_all(b"blocked").await }); + tokio::task::yield_now().await; + assert!(!write.is_finished()); + handle.close(); + assert!( + tokio::time::timeout(Duration::from_secs(1), write) + .await + .unwrap() + .unwrap() + .is_err() + ); + drop(right); +} + +#[tokio::test] +async fn closing_carrier_interrupts_blocked_io_and_releases_shared_state() { + let (left, _right) = tokio::io::duplex(1); + let (handle, incoming) = MuxHandle::start(left, MuxConfig::default()).unwrap(); + let weak = Arc::downgrade(&handle.shared); + tokio::task::yield_now().await; + handle.close(); + drop(handle); + drop(incoming); + tokio::time::timeout(Duration::from_secs(1), async { + while weak.upgrade().is_some() { + tokio::task::yield_now().await; + } + }) + .await + .expect("blocked I/O must release carrier state after close"); +} + +#[test] +fn profiles_outside_wire_window_limits_are_rejected() { + for (stream, connection) in [ + (MIB, 8 * MIB), + (4 * MIB, 4 * MIB), + (32 * MIB, 32 * MIB), + (4 * MIB + 1, 8 * MIB), + ] { + assert!( + MuxConfig { + stream_window_bytes: stream, + connection_window_bytes: connection, + ..MuxConfig::default() + } + .validate() + .is_err() + ); + } + assert!( + MuxConfig { + active_stream_limit: 0, + ..MuxConfig::default() + } + .validate() + .is_err() + ); } diff --git a/src/tests/mux/wire.rs b/src/tests/mux/wire.rs index 7cf3f5d..16548b5 100644 --- a/src/tests/mux/wire.rs +++ b/src/tests/mux/wire.rs @@ -1,32 +1,87 @@ use super::*; #[test] -fn header_has_stable_eight_byte_vector() { - let header = FrameHeader::stream(0x0102_0304, FLAG_SYN, 0x0506).unwrap(); - let encoded = encode_header(header).unwrap(); - assert_eq!(encoded, [1, FLAG_SYN, 0x05, 0x06, 1, 2, 3, 4]); - assert_eq!(decode_header(&encoded).unwrap(), header); +fn headers_have_stable_seven_byte_vectors() { + let cases = [ + ( + FrameHeader::open(0x0102_0304, 0x0506).unwrap(), + [1, 5, 6, 1, 2, 3, 4], + ), + ( + FrameHeader::data(0x0102_0304, 0x0506).unwrap(), + [2, 5, 6, 1, 2, 3, 4], + ), + ( + FrameHeader::window(0, 0x0506).unwrap(), + [3, 5, 6, 0, 0, 0, 0], + ), + ( + FrameHeader::close(0x0102_0304, CLOSE_FIN).unwrap(), + [4, 0, 0, 1, 2, 3, 4], + ), + ( + FrameHeader::close(0x0102_0304, CLOSE_RESET).unwrap(), + [5, 0, 0, 1, 2, 3, 4], + ), + ]; + for (header, encoded) in cases { + assert_eq!(encode_header(header).unwrap(), encoded); + assert_eq!(decode_header(&encoded).unwrap(), header); + } } #[test] -fn only_connection_window_accepts_zero_flow_id() { +fn only_window_accepts_zero_flow_id() { assert!(FrameHeader::window(0, 1).is_ok()); - assert!(FrameHeader::stream(0, 0, 1).is_err()); - assert_eq!( - FrameHeader { - kind: FrameKind::Datagram, - flags: 0, - value: 1, - flow_id: 0, + assert!(FrameHeader::open(0, 1).is_err()); + assert!(FrameHeader::data(0, 1).is_err()); + assert!(FrameHeader::close(0, CLOSE_FIN).is_err()); +} + +#[test] +fn invalid_codes_and_values_are_rejected() { + assert!(FrameHeader::data(1, 0).is_err()); + assert!(FrameHeader::window(1, 0).is_err()); + assert!(FrameHeader::close(1, CLOSE_FIN).is_ok()); + assert!(FrameHeader::close(1, CLOSE_RESET).is_ok()); + assert!(FrameHeader::close(1, 2).is_err()); +} + +#[test] +fn flow_ids_must_fit_the_shared_thirty_bit_space() { + for kind in [ + FrameKind::Open, + FrameKind::Data, + FrameKind::Window, + FrameKind::Fin, + FrameKind::Reset, + ] { + let value = u16::from(matches!(kind, FrameKind::Data | FrameKind::Window)); + let header = FrameHeader { + kind, + value, + flow_id: MAX_FLOW_ID, + }; + let bytes = encode_header(header).unwrap(); + assert_eq!(decode_header(&bytes).unwrap(), header); + for flow_id in [MAX_FLOW_ID + 1, u32::MAX] { + assert!(encode_header(FrameHeader { flow_id, ..header }).is_err()); + let mut invalid = bytes; + invalid[3..].copy_from_slice(&flow_id.to_be_bytes()); + assert!(decode_header(&invalid).is_err()); + } + for len in 0..HEADER_LEN { + assert!(decode_header(&bytes[..len]).is_err()); } - .validate(), - Err(WireError::InvalidFlowId) - ); + } } #[test] -fn reset_is_exclusive_and_empty() { - assert!(FrameHeader::stream(1, FLAG_RST, 0).is_ok()); - assert!(FrameHeader::stream(1, FLAG_RST | FLAG_FIN, 0).is_err()); - assert!(FrameHeader::stream(1, FLAG_RST, 1).is_err()); +fn decoder_rejects_unknown_types_and_nonzero_terminal_values() { + for kind in [0, 6, 0xff] { + assert!(decode_header(&[kind, 0, 0, 0, 0, 0, 1]).is_err()); + } + for kind in [4, 5] { + assert!(decode_header(&[kind, 0, 1, 0, 0, 0, 1]).is_err()); + } } diff --git a/src/tests/portal.rs b/src/tests/portal.rs index ae34377..f2d7db0 100644 --- a/src/tests/portal.rs +++ b/src/tests/portal.rs @@ -15,71 +15,88 @@ fn test_logger() -> Logger { #[test] fn empty_host_listens_on_both_wildcard_families() { let portal = Portal::new_with_listen_host( - Url::parse("portal://secret@localhost:2077?dial=127.0.0.1").unwrap(), + Url::parse("portal://secret@localhost:2000?dial=127.0.0.1").unwrap(), Some(""), test_logger(), ) .unwrap(); - assert_eq!(portal.inner.endpoint_addr, ":2077"); + assert_eq!(portal.inner.endpoint_addr, "*:2000"); assert_eq!( - portal.inner.bind_addrs, + portal.inner.tcp_bind_addrs, vec![ - SocketAddr::from(([0, 0, 0, 0], 2077)), - SocketAddr::from(([0u16; 8], 2077)), + SocketAddr::from(([0, 0, 0, 0], 2000)), + SocketAddr::from(([0u16; 8], 2000)), ] ); + assert_eq!(portal.inner.udp_bind_addrs, portal.inner.tcp_bind_addrs); assert_eq!(portal.inner.outbound.dialer_ip(), "127.0.0.1"); assert_eq!(portal.inner.network_mode, NetworkMode::Mix); assert_eq!( portal.effective_url(), - "portal://:2077?net=mix&tls=1&alpn=now/1&rate=0&etar=0&dial=127.0.0.1&socks=none&next=none" + "portal://*:2000?tls=1&rate=0&etar=0&dial=127.0.0.1&morph=0&socks=none&next=none" ); } #[test] fn explicit_wildcard_host_selects_one_address_family() { let ipv4 = Portal::new( - Url::parse("portal://secret@0.0.0.0:2077?dial=auto").unwrap(), + Url::parse("portal://secret@0.0.0.0:2000?dial=auto").unwrap(), test_logger(), ) .unwrap(); let ipv6 = Portal::new( - Url::parse("portal://secret@[::]:2077?dial=::1").unwrap(), + Url::parse("portal://secret@[::]:2000?dial=::1").unwrap(), test_logger(), ) .unwrap(); - assert_eq!(ipv4.inner.endpoint_addr, "0.0.0.0:2077"); + assert_eq!(ipv4.inner.endpoint_addr, "0.0.0.0:2000"); assert_eq!( - ipv4.inner.bind_addrs, - vec![SocketAddr::from(([0, 0, 0, 0], 2077))] + ipv4.inner.tcp_bind_addrs, + vec![SocketAddr::from(([0, 0, 0, 0], 2000))] ); assert_eq!(ipv4.inner.outbound.dialer_ip(), "auto"); - assert_eq!(ipv6.inner.endpoint_addr, "[::]:2077"); + assert_eq!(ipv6.inner.endpoint_addr, "[::]:2000"); assert_eq!( - ipv6.inner.bind_addrs, - vec![SocketAddr::from(([0u16; 8], 2077))] + ipv6.inner.tcp_bind_addrs, + vec![SocketAddr::from(([0u16; 8], 2000))] ); assert_eq!(ipv6.inner.outbound.dialer_ip(), "::1"); } #[test] -fn network_mode_accepts_supported_values_and_defaults_to_mix() { +fn explicit_carriers_have_independent_ports_and_families() { + let portal = Portal::new( + Url::parse("portal://secret@*/tcp4:2006/udp6:2017").unwrap(), + test_logger(), + ) + .unwrap(); + + assert_eq!(portal.inner.endpoint_addr, "*/tcp4:2006/udp6:2017"); + assert_eq!( + portal.inner.tcp_bind_addrs, + vec![SocketAddr::from(([0, 0, 0, 0], 2006))] + ); + assert_eq!( + portal.inner.udp_bind_addrs, + vec![SocketAddr::from(([0u16; 8], 2017))] + ); + assert_eq!(portal.inner.network_mode, NetworkMode::Mix); +} + +#[test] +fn carrier_paths_select_network_mode_and_net_is_ignored() { let cases = [ - ("", NetworkMode::Mix), - ("?net=mix", NetworkMode::Mix), - ("?net=tcp", NetworkMode::Tcp), - ("?net=udp", NetworkMode::Udp), + ("portal://secret@127.0.0.1:2000", NetworkMode::Mix), + ("portal://secret@127.0.0.1:2000?net=tcp", NetworkMode::Mix), + ("portal://secret@127.0.0.1/tcp:2006", NetworkMode::Tcp), + ("portal://secret@127.0.0.1/udp:2017", NetworkMode::Udp), ]; - for (query, expected) in cases { - let portal = Portal::new( - Url::parse(&format!("portal://secret@127.0.0.1:2077{query}")).unwrap(), - test_logger(), - ) - .unwrap(); + for (raw, expected) in cases { + let portal = Portal::new(Url::parse(raw).unwrap(), test_logger()).unwrap(); assert_eq!(portal.inner.network_mode, expected); } } @@ -92,19 +109,21 @@ fn network_mode_checkpoint_values_match_listener_modes() { } #[test] -fn network_mode_rejects_unknown_values() { - let error = Portal::new( - Url::parse("portal://secret@127.0.0.1:2077?net=auto").unwrap(), +fn net_is_an_ignored_unknown_parameter() { + let portal = Portal::new( + Url::parse("portal://secret@127.0.0.1:2000?net=auto").unwrap(), test_logger(), - ); + ) + .unwrap(); - assert!(error.is_err()); + assert_eq!(portal.inner.network_mode, NetworkMode::Mix); + assert!(!portal.effective_url().contains("net=")); } #[test] fn socks_configuration_is_validated_and_redacted_in_effective_url() { let portal = Portal::new( - Url::parse("portal://secret@127.0.0.1:2077?log=none&socks=user:p%40ss@proxy.test:1080") + Url::parse("portal://secret@127.0.0.1:2000?log=none&socks=user:p%40ss@proxy.test:1080") .unwrap(), test_logger(), ) @@ -115,7 +134,7 @@ fn socks_configuration_is_validated_and_redacted_in_effective_url() { assert!(!effective.contains("p@ss")); let duplicate = Portal::new( - Url::parse("portal://secret@127.0.0.1:2077?socks=proxy.test:1080&socks=other.test:1080") + Url::parse("portal://secret@127.0.0.1:2000?socks=proxy.test:1080&socks=other.test:1080") .unwrap(), test_logger(), ) @@ -124,9 +143,9 @@ fn socks_configuration_is_validated_and_redacted_in_effective_url() { } #[test] -fn native_next_defaults_to_quic_and_redacts_the_shared_key() { +fn native_next_defaults_to_tcp_without_mux_and_redacts_the_shared_key() { let portal = Portal::new( - Url::parse("portal://relay-key@127.0.0.1:2077?next=upstream%40key@relay.example:2080") + Url::parse("portal://relay-key@127.0.0.1:2000?next=upstream%40key@relay.example:2080") .unwrap(), test_logger(), ) @@ -135,7 +154,7 @@ fn native_next_defaults_to_quic_and_redacts_the_shared_key() { assert_eq!(portal.inner.outbound.next_endpoint(), "relay.example:2080"); assert_eq!( portal.inner.outbound.next_transport().as_deref(), - Some("up=udp down=udp mux=0 sni=none pin=none") + Some("up=tcp down=tcp mux=0 sni=none pin=none morph=0") ); let effective = portal.effective_url(); assert!(effective.contains("next=relay.example:2080")); @@ -143,23 +162,75 @@ fn native_next_defaults_to_quic_and_redacts_the_shared_key() { assert_eq!(portal.inner.outbound.ping_ms(), 0); } +#[test] +fn native_next_omitted_directions_keep_independent_tcp_defaults_and_explicit_mux() { + for endpoint in ["origin.example:2000", "origin.example/udp6:2017/tcp4:2006"] { + for (query, up, down) in [ + ("", "tcp", "tcp"), + ("&up=udp", "udp", "tcp"), + ("&down=udp", "tcp", "udp"), + ("&up=mix", "mix", "tcp"), + ("&down=mix", "tcp", "mix"), + ] { + for (mux_query, mux) in [("", 0), ("&mux=1", 1)] { + let raw = format!( + "portal://relay-key@*/udp4:2017?next=upstream-key@{endpoint}{query}{mux_query}" + ); + let portal = Portal::new(Url::parse(&raw).unwrap(), test_logger()).unwrap(); + assert_eq!(portal.inner.network_mode, NetworkMode::Udp); + assert_eq!( + portal.inner.outbound.next_transport().as_deref(), + Some( + format!("up={up} down={down} mux={mux} sni=none pin=none morph=0").as_str() + ), + "{raw}" + ); + assert!( + portal + .effective_url() + .contains(&format!("&up={up}&down={down}&mux={mux}&")), + "{raw}" + ); + } + } + } +} + +#[test] +fn native_next_uses_shared_endpoint_grammar_and_single_carrier_defaults() { + let portal = Portal::new( + Url::parse("portal://relay-key@*/tcp4:2006?next=upstream-key@relay.example/udp6:2017") + .unwrap(), + test_logger(), + ) + .unwrap(); + + assert_eq!(portal.inner.network_mode, NetworkMode::Tcp); + assert_eq!( + portal.inner.outbound.next_endpoint(), + "relay.example/udp6:2017" + ); + assert_eq!( + portal.inner.outbound.next_transport().as_deref(), + Some("up=udp down=udp mux=0 sni=none pin=none morph=0") + ); +} + #[test] fn native_next_reuses_transport_identity_and_source_binding() { let portal = Portal::new( Url::parse( - "portal://relay-key@127.0.0.1:2077?dial=127.0.0.2&alpn=private/2&next=secret@[::1]:2080&up=tcp&down=tcp&mux=1&sni=origin.example&pin=abc", + "portal://relay-key@127.0.0.1:2000?dial=127.0.0.2&alpn=private/2&next=secret@[::1]:2080&up=tcp&down=tcp&mux=1&sni=origin.example&pin=abc", ) .unwrap(), test_logger(), ) .unwrap(); - assert_eq!(portal.inner.alpn, "private/2"); - assert_eq!(portal.inner.outbound.dialer_ip(), "127.0.0.2"); assert_eq!(portal.inner.outbound.next_endpoint(), "[::1]:2080"); assert_eq!( portal.inner.outbound.next_transport().as_deref(), - Some("up=tcp down=tcp mux=1 sni=origin.example pin=abc") + Some("up=tcp down=tcp mux=1 sni=origin.example pin=abc morph=0") ); assert!( portal @@ -172,7 +243,7 @@ fn native_next_reuses_transport_identity_and_source_binding() { fn native_next_and_socks_are_mutually_exclusive() { let result = Portal::new( Url::parse( - "portal://relay-key@127.0.0.1:2077?next=secret@origin.example:2080&socks=127.0.0.1:1080", + "portal://relay-key@127.0.0.1:2000?next=secret@origin.example:2080&socks=127.0.0.1:1080", ) .unwrap(), test_logger(), @@ -180,7 +251,7 @@ fn native_next_and_socks_are_mutually_exclusive() { assert!(result.is_err()); let disabled_socks = Portal::new( - Url::parse("portal://relay-key@127.0.0.1:2077?next=secret@origin.example:2080&socks=none") + Url::parse("portal://relay-key@127.0.0.1:2000?next=secret@origin.example:2080&socks=none") .unwrap(), test_logger(), ); @@ -195,7 +266,7 @@ fn disabled_next_ignores_all_native_upstream_options() { "next=none&up=%GG&mux=%GG&pin=%FF", ] { let portal = Portal::new( - Url::parse(&format!("portal://relay-key@127.0.0.1:2077?{suffix}")).unwrap(), + Url::parse(&format!("portal://relay-key@127.0.0.1:2000?{suffix}")).unwrap(), test_logger(), ) .unwrap(); @@ -217,7 +288,7 @@ fn enabled_next_validates_only_effective_upstream_options() { ] { let result = Portal::new( Url::parse(&format!( - "portal://relay-key@127.0.0.1:2077?next=secret@origin.example:2080&{suffix}" + "portal://relay-key@127.0.0.1:2000?next=secret@origin.example:2080&{suffix}" )) .unwrap(), test_logger(), @@ -238,7 +309,7 @@ fn native_next_accepts_mix_and_normalizes_pure_udp_mux() { ] { let portal = Portal::new( Url::parse(&format!( - "portal://relay-key@127.0.0.1:2077?next=secret@origin.example:2080&up={up}&down={down}&mux=1" + "portal://relay-key@127.0.0.1:2000?next=secret@origin.example:2080&up={up}&down={down}&mux=1" )) .unwrap(), test_logger(), @@ -246,7 +317,7 @@ fn native_next_accepts_mix_and_normalizes_pure_udp_mux() { .unwrap(); assert_eq!( portal.inner.outbound.next_transport().as_deref(), - Some(format!("up={up} down={down} mux={mux} sni=none pin=none").as_str()), + Some(format!("up={up} down={down} mux={mux} sni=none pin=none morph=0").as_str()), ); assert!( portal @@ -260,7 +331,7 @@ fn native_next_accepts_mix_and_normalizes_pure_udp_mux() { fn next_uses_first_duplicate_and_rejects_empty_value() { let portal = Portal::new( Url::parse( - "portal://relay-key@127.0.0.1:2077?next=first@one.example:2080&next=second@two.example:2081", + "portal://relay-key@127.0.0.1:2000?next=first@one.example:2080&next=second@two.example:2081", ) .unwrap(), test_logger(), @@ -270,7 +341,7 @@ fn next_uses_first_duplicate_and_rejects_empty_value() { assert!( Portal::new( - Url::parse("portal://relay-key@127.0.0.1:2077?next=").unwrap(), + Url::parse("portal://relay-key@127.0.0.1:2000?next=").unwrap(), test_logger(), ) .is_err() @@ -280,7 +351,7 @@ fn next_uses_first_duplicate_and_rejects_empty_value() { #[test] fn direct_portal_reports_exact_zero_ping() { let portal = Portal::new( - Url::parse("portal://relay-key@127.0.0.1:2077").unwrap(), + Url::parse("portal://relay-key@127.0.0.1:2000").unwrap(), test_logger(), ) .unwrap(); @@ -291,7 +362,7 @@ fn direct_portal_reports_exact_zero_ping() { fn all_network_modes_reject_tls_zero() { for mode in ["mix", "tcp", "udp"] { let portal = Portal::new( - Url::parse(&format!("portal://secret@127.0.0.1:2077?tls=0&net={mode}")).unwrap(), + Url::parse(&format!("portal://secret@127.0.0.1:2000?tls=0&net={mode}")).unwrap(), test_logger(), ); assert!(portal.is_err()); @@ -300,17 +371,21 @@ fn all_network_modes_reject_tls_zero() { #[tokio::test] async fn network_mode_binds_only_selected_transports() { - for (query, expected_tcp, expected_udp) in [ - ("", 1, 1), - ("?net=mix", 1, 1), - ("?net=tcp", 1, 0), - ("?net=udp", 0, 1), - ] { + for (path, expected_tcp, expected_udp) in [("", 1, 1), ("/tcp:PORT", 1, 0), ("/udp:PORT", 0, 1)] + { let reservation = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = reservation.local_addr().unwrap().port(); drop(reservation); let portal = Portal::new( - Url::parse(&format!("portal://secret@127.0.0.1:{port}{query}")).unwrap(), + Url::parse(&if path.is_empty() { + format!("portal://secret@127.0.0.1:{port}") + } else { + format!( + "portal://secret@127.0.0.1{}", + path.replace("PORT", &port.to_string()) + ) + }) + .unwrap(), test_logger(), ) .unwrap(); @@ -325,15 +400,16 @@ async fn network_mode_binds_only_selected_transports() { #[test] fn portal_url_contract_rejects_invalid_structure_and_selected_values() { for raw in [ - "vector://secret@127.0.0.1:2077", - "portal://secret:password@127.0.0.1:2077", - "portal://secret@127.0.0.1:2077/path", - "portal://secret@127.0.0.1:2077#fragment", - "portal://secret@127.0.0.1:2077?net=", - "portal://secret@127.0.0.1:2077?alpn=", - "portal://secret@127.0.0.1:2077?socks=", - "portal://secret@127.0.0.1:2077?rate=-1", - "portal://secret@127.0.0.1:2077?dial=not-an-ip", + "vector://secret@127.0.0.1:2000", + "portal://secret:password@127.0.0.1:2000", + "portal://secret@127.0.0.1:2000/tcp:2006", + "portal://secret@127.0.0.1/not-a-carrier:2000", + "portal://secret@127.0.0.1:2000#fragment", + "portal://secret@127.0.0.1:2000?socks=", + "portal://secret@127.0.0.1:2000?rate=-1", + "portal://secret@127.0.0.1:2000?dial=not-an-ip", + "portal://secret@127.0.0.1:2000?morph=", + "portal://secret@127.0.0.1:2000?morph=2", "portal://secret@127.0.0.1:0", "portal://secret@127.0.0.1", ] { @@ -344,21 +420,39 @@ fn portal_url_contract_rejects_invalid_structure_and_selected_values() { } } +#[test] +fn outer_morph_controls_local_and_next_carriers() { + let portal = Portal::new( + Url::parse( + "portal://local-key@127.0.0.1:2000?morph=1&next=upstream-key@origin.example:2080", + ) + .unwrap(), + test_logger(), + ) + .unwrap(); + assert!(portal.inner.morph_keys.is_some()); + assert_eq!( + portal.inner.outbound.next_transport().as_deref(), + Some("up=tcp down=tcp mux=0 sni=none pin=none morph=1") + ); + assert!(portal.effective_url().contains("&morph=1&")); +} + #[test] fn portal_ignores_unknown_parameters_and_keeps_first_duplicate() { let portal = Portal::new( Url::parse( - "portal://secret@127.0.0.1:2077?unknown=value&spec=ignored&alpn=private/2&mux=2&pool=8&net=tcp&net=udp&rate=1&rate=2", + "portal://secret@127.0.0.1:2000?unknown=value&spec=ignored&alpn=private/2&mux=2&pool=8&net=tcp&net=udp&rate=1&rate=2", ) .unwrap(), test_logger(), ) .unwrap(); - assert_eq!(portal.inner.network_mode, NetworkMode::Tcp); + assert_eq!(portal.inner.network_mode, NetworkMode::Mix); assert_eq!(portal.inner.rate_limit, 1); - assert_eq!(portal.inner.alpn, "private/2"); - assert!(portal.effective_url().contains("?net=tcp&tls=1&")); - assert!(portal.effective_url().contains("alpn=private/2")); + assert!(portal.effective_url().contains("?tls=1&")); + assert!(!portal.effective_url().contains("net=")); + assert!(!portal.effective_url().contains("alpn=")); assert!(!portal.effective_url().contains("mux=")); assert!(!portal.effective_url().contains("pool=")); } @@ -368,13 +462,13 @@ fn portal_mux_is_ignored_without_next() { for value in ["", "0", "1", "2", "true"] { let portal = Portal::new( Url::parse(&format!( - "portal://secret@127.0.0.1:2077?alpn=private/2&mux={value}" + "portal://secret@127.0.0.1:2000?alpn=private/2&mux={value}" )) .unwrap(), test_logger(), ) .unwrap(); - assert_eq!(portal.inner.alpn, "private/2"); + assert!(!portal.effective_url().contains("alpn=")); assert!(!portal.effective_url().contains("mux=")); } } @@ -382,11 +476,11 @@ fn portal_mux_is_ignored_without_next() { #[test] fn certificate_parameters_are_tied_to_ca_trusted_mode() { for raw in [ - "portal://secret@127.0.0.1:2077?crt=cert.pem", - "portal://secret@127.0.0.1:2077?key=key.pem", - "portal://secret@127.0.0.1:2077?crt=cert.pem&key=key.pem", - "portal://secret@127.0.0.1:2077?tls=2&crt=cert.pem", - "portal://secret@127.0.0.1:2077?tls=2&key=key.pem", + "portal://secret@127.0.0.1:2000?crt=cert.pem", + "portal://secret@127.0.0.1:2000?key=key.pem", + "portal://secret@127.0.0.1:2000?crt=cert.pem&key=key.pem", + "portal://secret@127.0.0.1:2000?tls=2&crt=cert.pem", + "portal://secret@127.0.0.1:2000?tls=2&key=key.pem", ] { assert!(Portal::new(Url::parse(raw).unwrap(), test_logger()).is_err()); } diff --git a/src/tests/portal/config.rs b/src/tests/portal/config.rs index b79c3a4..f8d6ed5 100644 --- a/src/tests/portal/config.rs +++ b/src/tests/portal/config.rs @@ -18,12 +18,6 @@ fn parse(values: &[(&str, &str)]) -> anyhow::Result { #[test] fn absent_values_use_the_existing_defaults() { let config = parse(&[]).unwrap(); - assert_eq!(config.max_tcp_flows, DEFAULT_MAX_TCP_FLOWS); - assert_eq!(config.max_udp_flows, DEFAULT_MAX_UDP_FLOWS); - assert_eq!( - config.quic_bidi_stream_capacity(), - DEFAULT_MAX_TCP_FLOWS + DEFAULT_MAX_UDP_FLOWS as u32 - ); assert_eq!(config.udp_queue_bytes, DEFAULT_QUIC_UDP_QUEUE_BYTES); assert_eq!(config.tcp_data_buf_size, DEFAULT_TCP_DATA_BUF_SIZE); assert_eq!(config.udp_data_buf_size, DEFAULT_UDP_DATA_BUF_SIZE); @@ -39,19 +33,15 @@ fn absent_values_use_the_existing_defaults() { ); assert_eq!(config.shutdown_timeout, DEFAULT_SHUTDOWN_TIMEOUT); assert_eq!(config.reload_interval, DEFAULT_RELOAD_INTERVAL); - assert_eq!(config.max_pending_pairs, DEFAULT_MAX_PENDING_PAIRS); assert_eq!(config.flow_pair_timeout, DEFAULT_FLOW_PAIR_TIMEOUT); } #[test] fn all_integer_limits_reject_zero_instead_of_falling_back() { for name in [ - "NOW_MAX_TCP_FLOWS", - "NOW_MAX_UDP_FLOWS", "NOW_QUIC_UDP_QUEUE_BYTES", "NOW_TCP_DATA_BUF_SIZE", "NOW_UDP_DATA_BUF_SIZE", - "NOW_MAX_PENDING_PAIRS", ] { let error = parse(&[(name, "0")]).unwrap_err().to_string(); assert!(error.contains(name), "unexpected error for {name}: {error}"); @@ -60,8 +50,10 @@ fn all_integer_limits_reject_zero_instead_of_falling_back() { #[test] fn removed_quic_specific_udp_limit_is_not_an_input() { - let config = parse(&[("NOW_QUIC_MAX_UDP_FLOWS", "13")]).unwrap(); - assert_eq!(config.max_udp_flows, DEFAULT_MAX_UDP_FLOWS); + assert_eq!( + parse(&[("NOW_QUIC_MAX_UDP_FLOWS", "13")]).unwrap(), + parse(&[]).unwrap() + ); } #[test] @@ -88,8 +80,6 @@ fn all_durations_reject_zero_and_invalid_syntax() { #[test] fn values_are_parsed_once_into_typed_fields() { let config = parse(&[ - ("NOW_MAX_TCP_FLOWS", "77"), - ("NOW_MAX_UDP_FLOWS", "13"), ("NOW_QUIC_UDP_QUEUE_BYTES", "8192"), ("NOW_TCP_DATA_BUF_SIZE", "4096"), ("NOW_UDP_DATA_BUF_SIZE", "8192"), @@ -102,14 +92,10 @@ fn values_are_parsed_once_into_typed_fields() { ("NOW_TELEMETRY_INTERVAL", "1700ms"), ("NOW_SHUTDOWN_TIMEOUT", "1800ms"), ("NOW_RELOAD_INTERVAL", "1900ms"), - ("NOW_MAX_PENDING_PAIRS", "19"), ("NOW_FLOW_PAIR_TIMEOUT", "2s"), ]) .unwrap(); - assert_eq!(config.max_tcp_flows, 77); - assert_eq!(config.max_udp_flows, 13); - assert_eq!(config.quic_bidi_stream_capacity(), 90); assert_eq!(config.udp_queue_bytes, 8192); assert_eq!(config.tcp_data_buf_size, 4096); assert_eq!(config.udp_data_buf_size, 8192); @@ -122,7 +108,6 @@ fn values_are_parsed_once_into_typed_fields() { assert_eq!(config.telemetry_interval, Duration::from_millis(1700)); assert_eq!(config.shutdown_timeout, Duration::from_millis(1800)); assert_eq!(config.reload_interval, Duration::from_millis(1900)); - assert_eq!(config.max_pending_pairs, 19); assert_eq!(config.flow_pair_timeout, Duration::from_secs(2)); } @@ -146,14 +131,19 @@ fn telemetry_interval_enforces_dashboard_bounds() { #[test] fn overflow_is_a_startup_error() { - assert!(parse(&[("NOW_MAX_TCP_FLOWS", "4294967296")]).is_err()); - assert!( - parse(&[ - ("NOW_MAX_TCP_FLOWS", "4294967295"), - ("NOW_MAX_UDP_FLOWS", "1"), - ]) - .is_err() - ); assert!(parse(&[("NOW_TCP_DATA_BUF_SIZE", "999999999999999999999999")]).is_err()); assert!(parse(&[("NOW_HANDSHAKE_TIMEOUT", "999999999999999999999999h")]).is_err()); } + +#[test] +fn removed_application_count_limits_are_ignored() { + for name in [ + "NOW_MAX_TCP_FLOWS", + "NOW_MAX_UDP_FLOWS", + "NOW_MAX_PENDING_PAIRS", + ] { + for value in ["0", "1", "invalid"] { + assert_eq!(parse(&[(name, value)]).unwrap(), parse(&[]).unwrap()); + } + } +} diff --git a/src/tests/portal/conn/asymmetric.rs b/src/tests/portal/conn/asymmetric.rs index 48fa1b4..656c76d 100644 --- a/src/tests/portal/conn/asymmetric.rs +++ b/src/tests/portal/conn/asymmetric.rs @@ -112,7 +112,7 @@ async fn authenticate_quic(portal: &Portal, conn: &quinn::Connection, session: [ } fn request(header: FlowHeader, target: SocketAddr, payload: &[u8]) -> Vec { - let mut out = write_flow_header(header).to_vec(); + let mut out = write_flow_header(header).unwrap().to_vec(); if matches!(header.role, FlowRole::Open | FlowRole::Duplex) { out.extend_from_slice(&write_request_frame(&Target::ip(target).unwrap()).unwrap()); } diff --git a/src/tests/portal/conn/quic.rs b/src/tests/portal/conn/quic.rs index 78ca7cb..face744 100644 --- a/src/tests/portal/conn/quic.rs +++ b/src/tests/portal/conn/quic.rs @@ -10,7 +10,7 @@ use bytes::Bytes; use tokio::net::UdpSocket; use tokio::time::timeout; -use crate::portal::{Portal, UdpFlowLimits}; +use crate::portal::Portal; use crate::protocol::{ Carrier, FlowErrorCode, FlowHeader, FlowKind, FlowResult, FlowRole, UdpFrame, decode_udp_frame, encode_udp_close, encode_udp_data_fragments, read_flow_result, write_flow_header, @@ -18,8 +18,7 @@ use crate::protocol::{ }; use super::support::{ - connect_test_quic, connect_test_quic_to, connect_test_quic_with_url_and_limits, - quic_auth_frame, stop_test_quic, test_target, + connect_test_quic, connect_test_quic_to, quic_auth_frame, stop_test_quic, test_target, }; async fn authenticate_test_connection(portal: &Portal, connection: &quinn::Connection) { @@ -43,14 +42,17 @@ async fn setup_quic_udp( target: &str, ) -> (FlowResult, quinn::RecvStream) { let (mut send, mut recv) = connection.open_bi().await.unwrap(); - send.write_all(&write_flow_header(FlowHeader { - role: FlowRole::Duplex, - flow_id, - kind: FlowKind::Udp, - uplink: Carrier::Quic, - downlink: Carrier::Quic, - hops: 0, - })) + send.write_all( + &write_flow_header(FlowHeader { + role: FlowRole::Duplex, + flow_id, + kind: FlowKind::Udp, + uplink: Carrier::Quic, + downlink: Carrier::Quic, + hops: 0, + }) + .unwrap(), + ) .await .unwrap(); send.write_all(&write_request_frame(&test_target(target)).unwrap()) @@ -142,14 +144,17 @@ async fn quic_carrier_mismatch_returns_invalid_request() { authenticate_test_connection(&portal, &connection).await; let (mut send, mut recv) = connection.open_bi().await.unwrap(); - send.write_all(&write_flow_header(FlowHeader { - role: FlowRole::Duplex, - flow_id: 76, - kind: FlowKind::Tcp, - uplink: Carrier::TlsTcp, - downlink: Carrier::TlsTcp, - hops: 0, - })) + send.write_all( + &write_flow_header(FlowHeader { + role: FlowRole::Duplex, + flow_id: 76, + kind: FlowKind::Tcp, + uplink: Carrier::TlsTcp, + downlink: Carrier::TlsTcp, + hops: 0, + }) + .unwrap(), + ) .await .unwrap(); send.finish().unwrap(); @@ -182,14 +187,17 @@ async fn first_stream_carries_auth_and_flow_while_pre_auth_datagrams_are_dropped send.write_all(&quic_auth_frame(&portal, &connection, [78; 16])) .await .unwrap(); - send.write_all(&write_flow_header(FlowHeader { - role: FlowRole::Duplex, - flow_id: 78, - kind: FlowKind::Udp, - uplink: Carrier::Quic, - downlink: Carrier::Quic, - hops: 0, - })) + send.write_all( + &write_flow_header(FlowHeader { + role: FlowRole::Duplex, + flow_id: 78, + kind: FlowKind::Udp, + uplink: Carrier::Quic, + downlink: Carrier::Quic, + hops: 0, + }) + .unwrap(), + ) .await .unwrap(); send.write_all(&write_request_frame(&test_target(&target_addr)).unwrap()) @@ -322,59 +330,30 @@ async fn zero_length_udp_packet_round_trips_as_data() { } #[tokio::test] -async fn udp_close_releases_the_session_global_flow_permit() { - let limits = UdpFlowLimits { - max_flows: 1, - queue_bytes: 64 * 1024, - }; +async fn quic_udp_grows_past_former_session_and_stream_limits() { let (portal, server_endpoint, client_endpoint, connection, shutdown, server_task) = - connect_test_quic_with_url_and_limits( - "portal://secret@127.0.0.1:0?log=none&net=udp", - Some(limits), - ) - .await; + connect_test_quic().await; authenticate_test_connection(&portal, &connection).await; - let first_target = UdpSocket::bind("127.0.0.1:0").await.unwrap(); - let second_target = UdpSocket::bind("127.0.0.1:0").await.unwrap(); - - assert_eq!( - setup_quic_udp( - &portal, - &connection, - 90, - &first_target.local_addr().unwrap().to_string(), - ) - .await - .0, - FlowResult::Ready - ); - let (result, mut rejected) = setup_quic_udp( - &portal, - &connection, - 91, - &second_target.local_addr().unwrap().to_string(), - ) - .await; - assert_eq!(result, FlowResult::Reject(FlowErrorCode::FlowLimit)); - let mut eof = [0u8; 1]; - assert_eq!(rejected.read(&mut eof).await.unwrap(), None); - + let target = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let address = target.local_addr().unwrap().to_string(); + let mut streams = Vec::new(); + timeout(Duration::from_secs(20), async { + for id in 1..=1400 { + let (result, stream) = setup_quic_udp(&portal, &connection, id, &address).await; + assert_eq!(result, FlowResult::Ready, "flow {id}"); + streams.push(stream); + } + }) + .await + .expect("MAX_STREAMS must grow without a fixed active-flow cap"); + wait_for_udp_active(&portal, 1400).await; connection - .send_datagram(Bytes::copy_from_slice(&encode_udp_close(90).unwrap())) + .send_datagram(Bytes::copy_from_slice(&encode_udp_close(1).unwrap())) .unwrap(); - wait_for_udp_active(&portal, 0).await; - assert_eq!( - setup_quic_udp( - &portal, - &connection, - 91, - &second_target.local_addr().unwrap().to_string(), - ) - .await - .0, - FlowResult::Ready - ); - + wait_for_udp_active(&portal, 1399).await; + let (result, stream) = setup_quic_udp(&portal, &connection, 1401, &address).await; + assert_eq!(result, FlowResult::Ready); + streams.push(stream); connection.close(quinn::VarInt::from_u32(0), b""); stop_test_quic(server_endpoint, client_endpoint, shutdown, server_task).await; } diff --git a/src/tests/portal/conn/relay.rs b/src/tests/portal/conn/relay.rs index 1291cf3..50b98aa 100644 --- a/src/tests/portal/conn/relay.rs +++ b/src/tests/portal/conn/relay.rs @@ -23,11 +23,11 @@ fn symmetric_exchange_path( fn paired_path_contains_both_carriers_and_both_client_links() { let uplink = LinkPath { peer: "198.51.100.1:1000".into(), - local: "192.0.2.1:2077".into(), + local: "192.0.2.1:2000".into(), }; let downlink = LinkPath { peer: "[2001:db8::2]:2000".into(), - local: "[2001:db8::1]:2077".into(), + local: "[2001:db8::1]:2000".into(), }; assert_eq!( paired_exchange_path( @@ -38,7 +38,7 @@ fn paired_path_contains_both_carriers_and_both_client_links() { Carrier::Quic, &downlink, ), - "UP[TCP] 198.51.100.1:1000 -> 192.0.2.1:2077 -> 192.0.2.1:3000 -> target.test:443 | DOWN[UDP] target.test:443 -> 192.0.2.1:3000 -> [2001:db8::1]:2077 -> [2001:db8::2]:2000" + "UP[TCP] 198.51.100.1:1000 -> 192.0.2.1:2000 -> 192.0.2.1:3000 -> target.test:443 | DOWN[UDP] target.test:443 -> 192.0.2.1:3000 -> [2001:db8::1]:2000 -> [2001:db8::2]:2000" ); } @@ -48,11 +48,11 @@ fn symmetric_path_uses_the_same_carrier_prefix() { symmetric_exchange_path( Carrier::Quic, "198.51.100.1:1000", - "192.0.2.1:2077", + "192.0.2.1:2000", "192.0.2.1:3000", "target.test:443", ), - "UP[UDP] 198.51.100.1:1000 -> 192.0.2.1:2077 -> 192.0.2.1:3000 -> target.test:443 | DOWN[UDP] target.test:443 -> 192.0.2.1:3000 -> 192.0.2.1:2077 -> 198.51.100.1:1000" + "UP[UDP] 198.51.100.1:1000 -> 192.0.2.1:2000 -> 192.0.2.1:3000 -> target.test:443 | DOWN[UDP] target.test:443 -> 192.0.2.1:3000 -> 192.0.2.1:2000 -> 198.51.100.1:1000" ); } diff --git a/src/tests/portal/conn/session_flow.rs b/src/tests/portal/conn/session_flow.rs index 39288c7..741e1b2 100644 --- a/src/tests/portal/conn/session_flow.rs +++ b/src/tests/portal/conn/session_flow.rs @@ -119,6 +119,59 @@ fn packet_budget_rejects_new_slot_without_leaking_permits() { assert_eq!(budget.available_permits(), 5); } +#[test] +fn failed_replacement_admission_preserves_the_existing_partial_packet() { + let budget = Arc::new(Semaphore::new(6)); + let now = Instant::now(); + let mut reassembler = DatagramReassembler::new(ReassemblyConfig { + max_slots: 1, + max_bytes: 12, + ..ReassemblyConfig::default() + }); + assert!(matches!( + push( + &mut reassembler, + 9, + fragment(3, 0, 2, 6, b"abc"), + now, + &budget, + ), + ReassemblyOutcome::Pending { .. } + )); + assert_eq!(budget.available_permits(), 0); + + assert!(matches!( + push( + &mut reassembler, + 10, + fragment(4, 0, 2, 6, b"xyz"), + now, + &budget, + ), + ReassemblyOutcome::Dropped(ReassemblyDropReason::ByteLimit) + )); + assert_eq!(reassembler.slot_count(), 1); + assert_eq!(reassembler.reserved_bytes(), 6); + + let (payload, reservation) = match push( + &mut reassembler, + 9, + fragment(3, 1, 2, 6, b"def"), + now, + &budget, + ) { + ReassemblyOutcome::Complete { + payload, + reservation, + .. + } => (payload, reservation), + _ => panic!("existing partial packet must survive failed replacement admission"), + }; + assert_eq!(payload, b"abcdef"[..]); + drop(reservation); + assert_eq!(budget.available_permits(), 6); +} + #[test] fn remove_flow_releases_only_that_flows_partial_reservations() { let budget = Arc::new(Semaphore::new(20)); diff --git a/src/tests/portal/conn/support.rs b/src/tests/portal/conn/support.rs index dd2ef04..1f63b12 100644 --- a/src/tests/portal/conn/support.rs +++ b/src/tests/portal/conn/support.rs @@ -71,6 +71,15 @@ impl ServerCertVerifier for AcceptAnyServerCertificate { pub(super) async fn connect_test_tls( listen_addr: SocketAddr, ) -> tokio_rustls::client::TlsStream { + connect_test_tls_with_alpns(listen_addr, vec![b"nw2".to_vec()]) + .await + .unwrap() +} + +pub(super) async fn connect_test_tls_with_alpns( + listen_addr: SocketAddr, + alpns: Vec>, +) -> std::io::Result> { let provider = Arc::new(rustls::crypto::ring::default_provider()); let mut client_config = rustls::ClientConfig::builder_with_provider(provider) .with_protocol_versions(&[&rustls::version::TLS13]) @@ -78,16 +87,15 @@ pub(super) async fn connect_test_tls( .dangerous() .with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertificate)) .with_no_client_auth(); - client_config.alpn_protocols = vec![b"now/1".to_vec()]; + client_config.alpn_protocols = alpns; let connector = TlsConnector::from(Arc::new(client_config)); - let stream = TcpStream::connect(listen_addr).await.unwrap(); + let stream = TcpStream::connect(listen_addr).await?; connector .connect( ServerName::try_from("localhost").unwrap().to_owned(), stream, ) .await - .unwrap() } pub(super) fn tls_auth_frame( @@ -178,9 +186,6 @@ pub(super) async fn connect_test_quic_with_url_and_limits( let inner = Arc::get_mut(&mut portal.inner).unwrap(); inner.udp_flow_limits = limits; inner.pairing = Arc::new(crate::portal::pairing::PairingRegistry::new( - inner.runtime.max_tcp_flows as usize, - limits.max_flows, - inner.runtime.max_pending_pairs, inner.runtime.flow_pair_timeout, )); } @@ -207,7 +212,7 @@ pub(super) async fn connect_test_quic_with_url_and_limits( .dangerous() .with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertificate)) .with_no_client_auth(); - rustls_config.alpn_protocols = vec![b"now/1".to_vec()]; + rustls_config.alpn_protocols = vec![b"nw2".to_vec()]; let quic_crypto = QuicClientConfig::try_from(rustls_config).unwrap(); let mut client_endpoint = quinn::Endpoint::client(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); @@ -236,7 +241,7 @@ pub(super) async fn connect_test_quic_to(listen_addr: SocketAddr) -> (quinn::End .dangerous() .with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertificate)) .with_no_client_auth(); - rustls_config.alpn_protocols = vec![b"now/1".to_vec()]; + rustls_config.alpn_protocols = vec![b"nw2".to_vec()]; let quic_crypto = QuicClientConfig::try_from(rustls_config).unwrap(); let mut endpoint = quinn::Endpoint::client(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic_crypto))); diff --git a/src/tests/portal/conn/tcp.rs b/src/tests/portal/conn/tcp.rs index 91fcb2d..71c8d22 100644 --- a/src/tests/portal/conn/tcp.rs +++ b/src/tests/portal/conn/tcp.rs @@ -25,8 +25,8 @@ use crate::protocol::{ use super::super::*; use super::support::{ - TestSocksAuth, connect_test_tls, spawn_test_socks5_tcp, spawn_test_socks5_udp, test_target, - tls_auth_frame, + TestSocksAuth, connect_test_tls, connect_test_tls_with_alpns, spawn_test_socks5_tcp, + spawn_test_socks5_udp, test_target, tls_auth_frame, }; fn duplex_setup(flow_id: u32, kind: FlowKind, target: &str) -> Vec { @@ -38,11 +38,53 @@ fn duplex_setup(flow_id: u32, kind: FlowKind, target: &str) -> Vec { downlink: Carrier::TlsTcp, hops: 0, }) + .unwrap() .to_vec(); setup.extend_from_slice(&write_request_frame(&test_target(target)).unwrap()); setup } +#[tokio::test] +async fn portal_rejects_client_hello_without_nw2() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let listen_addr = listener.local_addr().unwrap(); + let portal = Portal::new( + Url::parse("portal://secret@127.0.0.1:2000?log=none&net=tcp").unwrap(), + Logger::new(LogLevel::None, false), + ) + .unwrap(); + let portal_inner = portal.inner.clone(); + let shutdown = CancellationToken::new(); + let child_shutdown = shutdown.clone(); + let server_task = tokio::spawn(async move { + for _ in 0..3 { + let (stream, peer) = listener.accept().await.unwrap(); + let admission = portal_inner + .unauthenticated_admission + .try_acquire(peer.ip()) + .unwrap(); + handle_tcp_incoming( + portal_inner.clone(), + stream, + peer, + admission, + child_shutdown.clone(), + ) + .await; + } + }); + + for alpns in [vec![b"now/1".to_vec()], vec![b"private/2".to_vec()], vec![]] { + assert!( + connect_test_tls_with_alpns(listen_addr, alpns) + .await + .is_err() + ); + } + shutdown.cancel(); + server_task.await.unwrap(); +} + #[tokio::test] async fn portal_accepts_delayed_dedicated_flow_header() { let echo_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -58,7 +100,7 @@ async fn portal_accepts_delayed_dedicated_flow_header() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listen_addr = listener.local_addr().unwrap(); let portal = Portal::new( - Url::parse("portal://secret@127.0.0.1:2077?log=none&net=tcp").unwrap(), + Url::parse("portal://secret@127.0.0.1:2000?log=none&net=tcp").unwrap(), Logger::new(LogLevel::None, false), ) .unwrap(); @@ -111,7 +153,7 @@ async fn tls_tcp_relays_through_socks5_connect() { let listen_addr = listener.local_addr().unwrap(); let portal = Portal::new( Url::parse(&format!( - "portal://secret@127.0.0.1:2077?log=none&net=tcp&socks={socks_addr}" + "portal://secret@127.0.0.1:2000?log=none&net=tcp&socks={socks_addr}" )) .unwrap(), Logger::new(LogLevel::None, false), @@ -164,7 +206,7 @@ async fn tls_tcp_uot_relays_udp_and_counts_logical_udp() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listen_addr = listener.local_addr().unwrap(); let portal = Portal::new( - Url::parse("portal://secret@127.0.0.1:2077?log=none&net=tcp").unwrap(), + Url::parse("portal://secret@127.0.0.1:2000?log=none&net=tcp").unwrap(), Logger::new(LogLevel::None, false), ) .unwrap(); @@ -220,7 +262,7 @@ async fn tls_tcp_uot_relays_through_authenticated_socks5_udp() { let listen_addr = listener.local_addr().unwrap(); let portal = Portal::new( Url::parse(&format!( - "portal://secret@127.0.0.1:2077?log=none&net=tcp&socks=user:pass@{socks_addr}" + "portal://secret@127.0.0.1:2000?log=none&net=tcp&socks=user:pass@{socks_addr}" )) .unwrap(), Logger::new(LogLevel::None, false), @@ -265,7 +307,7 @@ async fn tls_tcp_auth_failure_waits_for_deadline_without_application_response() let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listen_addr = listener.local_addr().unwrap(); let portal = Portal::new( - Url::parse("portal://secret@127.0.0.1:2077?log=none&net=tcp").unwrap(), + Url::parse("portal://secret@127.0.0.1:2000?log=none&net=tcp").unwrap(), Logger::new(LogLevel::None, false), ) .unwrap(); @@ -305,7 +347,7 @@ async fn tls_tcp_flow_header_timeout_closes_unused_connection() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listen_addr = listener.local_addr().unwrap(); let portal = Portal::new( - Url::parse("portal://secret@127.0.0.1:2077?log=none&net=tcp").unwrap(), + Url::parse("portal://secret@127.0.0.1:2000?log=none&net=tcp").unwrap(), Logger::new(LogLevel::None, false), ) .unwrap(); @@ -346,7 +388,7 @@ async fn tls_mux_carrier_closes_after_becoming_fully_idle() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listen_addr = listener.local_addr().unwrap(); let portal = Portal::new( - Url::parse("portal://secret@127.0.0.1:2077?log=none&net=tcp").unwrap(), + Url::parse("portal://secret@127.0.0.1:2000?log=none&net=tcp").unwrap(), Logger::new(LogLevel::None, false), ) .unwrap(); @@ -381,6 +423,10 @@ async fn tls_mux_carrier_closes_after_becoming_fully_idle() { .await .unwrap() .unwrap(); + let mut window = [0_u8; 7]; + tls.read_exact(&mut window).await.unwrap(); + assert_eq!(window[0], 0x03); + assert_eq!(&window[3..], &[0, 0, 0, 0]); let mut byte = [0_u8; 1]; let read = tls.read(&mut byte).await; assert!( @@ -406,7 +452,7 @@ async fn tls_tcp_coalesced_auth_and_flow_bootstrap_relays() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listen_addr = listener.local_addr().unwrap(); let portal = Portal::new( - Url::parse("portal://secret@127.0.0.1:2077?log=none&net=tcp").unwrap(), + Url::parse("portal://secret@127.0.0.1:2000?log=none&net=tcp").unwrap(), Logger::new(LogLevel::None, false), ) .unwrap(); @@ -446,7 +492,7 @@ async fn tls_tcp_carrier_mismatch_returns_invalid_request() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listen_addr = listener.local_addr().unwrap(); let portal = Portal::new( - Url::parse("portal://secret@127.0.0.1:2077?log=none&net=tcp").unwrap(), + Url::parse("portal://secret@127.0.0.1:2000?log=none&net=tcp").unwrap(), Logger::new(LogLevel::None, false), ) .unwrap(); @@ -464,14 +510,17 @@ async fn tls_tcp_carrier_mismatch_returns_invalid_request() { let mut tls = connect_test_tls(listen_addr).await; let mut bootstrap = tls_auth_frame(&portal, &tls, [11; 16]).to_vec(); - bootstrap.extend_from_slice(&write_flow_header(FlowHeader { - role: FlowRole::Duplex, - flow_id: 11, - kind: FlowKind::Tcp, - uplink: Carrier::Quic, - downlink: Carrier::Quic, - hops: 0, - })); + bootstrap.extend_from_slice( + &write_flow_header(FlowHeader { + role: FlowRole::Duplex, + flow_id: 11, + kind: FlowKind::Tcp, + uplink: Carrier::Quic, + downlink: Carrier::Quic, + hops: 0, + }) + .unwrap(), + ); tls.write_all(&bootstrap).await.unwrap(); assert_eq!( @@ -488,7 +537,7 @@ async fn mismatched_open_leaves_invalid_request_for_later_attach() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listen_addr = listener.local_addr().unwrap(); let portal = Portal::new( - Url::parse("portal://secret@127.0.0.1:2077?log=none&net=tcp").unwrap(), + Url::parse("portal://secret@127.0.0.1:2000?log=none&net=tcp").unwrap(), Logger::new(LogLevel::None, false), ) .unwrap(); @@ -524,7 +573,7 @@ async fn mismatched_open_leaves_invalid_request_for_later_attach() { }; let mut first = connect_test_tls(listen_addr).await; let mut bootstrap = tls_auth_frame(&portal, &first, session_id).to_vec(); - bootstrap.extend_from_slice(&write_flow_header(open)); + bootstrap.extend_from_slice(&write_flow_header(open).unwrap()); first.write_all(&bootstrap).await.unwrap(); let mut eof = [0u8; 1]; match first.read(&mut eof).await { @@ -535,10 +584,13 @@ async fn mismatched_open_leaves_invalid_request_for_later_attach() { let mut second = connect_test_tls(listen_addr).await; let mut attach = tls_auth_frame(&portal, &second, session_id).to_vec(); - attach.extend_from_slice(&write_flow_header(FlowHeader { - role: FlowRole::Attach, - ..open - })); + attach.extend_from_slice( + &write_flow_header(FlowHeader { + role: FlowRole::Attach, + ..open + }) + .unwrap(), + ); second.write_all(&attach).await.unwrap(); assert_eq!( read_flow_result(&mut second).await.unwrap(), diff --git a/src/tests/portal/conn/vector.rs b/src/tests/portal/conn/vector.rs index 132b825..fe37156 100644 --- a/src/tests/portal/conn/vector.rs +++ b/src/tests/portal/conn/vector.rs @@ -25,6 +25,7 @@ use crate::transport::Stats; use crate::vector::{PortalClient, PortalClientConfig, Vector}; const TEST_TIMEOUT: Duration = Duration::from_secs(10); +const FULL_DUPLEX_TIMEOUT: Duration = Duration::from_secs(60); const ROUTE_POLICY_MATRIX: [(&str, &str); 9] = [ ("tcp", "tcp"), ("tcp", "udp"), @@ -108,11 +109,27 @@ async fn reserve_tcp_port() -> (u16, TcpListener) { (listener.local_addr().unwrap().port(), listener) } +async fn reserve_udp_port_except(excluded: u16) -> (u16, UdpSocket) { + loop { + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let port = socket.local_addr().unwrap().port(); + if port != excluded { + return (port, socket); + } + } +} + async fn start_runtime(up: &str, down: &str, mux: u8) -> TestRuntime { - let (portal_port, tcp_reservation, udp_reservation) = reserve_mixed_port().await; + start_runtime_with_morph(up, down, mux, false).await +} + +async fn start_runtime_with_morph(up: &str, down: &str, mux: u8, morph: bool) -> TestRuntime { + let (tcp_port, tcp_reservation) = reserve_tcp_port().await; + let (udp_port, udp_reservation) = reserve_udp_port_except(tcp_port).await; let portal = Portal::new( Url::parse(&format!( - "portal://secret@127.0.0.1:{portal_port}?log=none&net=mix" + "portal://secret@127.0.0.1/tcp:{tcp_port}/udp:{udp_port}?log=none&morph={}", + u8::from(morph) )) .unwrap(), Logger::new(LogLevel::None, false), @@ -139,7 +156,8 @@ async fn start_runtime(up: &str, down: &str, mux: u8) -> TestRuntime { let (socks_port, socks_reservation) = reserve_tcp_port().await; let vector = Vector::new( Url::parse(&format!( - "vector://secret@127.0.0.1:{portal_port}?log=none&up={up}&down={down}&mux={mux}&socks=127.0.0.1:{socks_port}" + "vector://secret@127.0.0.1/tcp:{tcp_port}/udp:{udp_port}?log=none&up={up}&down={down}&mux={mux}&morph={}&socks=127.0.0.1:{socks_port}", + u8::from(morph) )) .unwrap(), Logger::new(LogLevel::None, false), @@ -159,6 +177,44 @@ async fn start_runtime(up: &str, down: &str, mux: u8) -> TestRuntime { } } +#[tokio::test] +async fn morph_relays_every_fixed_tcp_udp_route() { + for (up, down) in [ + ("tcp", "tcp"), + ("tcp", "udp"), + ("udp", "tcp"), + ("udp", "udp"), + ] { + let target = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let target_address = target.local_addr().unwrap(); + let echo = tokio::spawn(async move { + let (mut stream, _) = target.accept().await.unwrap(); + let mut request = [0u8; 5]; + stream.read_exact(&mut request).await.unwrap(); + assert_eq!(&request, b"morph"); + stream.write_all(b"works").await.unwrap(); + }); + let runtime = start_runtime_with_morph(up, down, 0, true).await; + timeout(TEST_TIMEOUT, async { + let mut socks = TcpStream::connect(runtime.socks).await.unwrap(); + negotiate_socks(&mut socks).await; + socks + .write_all(&ip_request(1, target_address)) + .await + .unwrap(); + read_ipv4_reply(&mut socks).await; + socks.write_all(b"morph").await.unwrap(); + let mut response = [0u8; 5]; + socks.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"works", "up={up} down={down}"); + }) + .await + .unwrap(); + echo.await.unwrap(); + runtime.stop().await; + } +} + #[tokio::test] async fn mux_symmetric_carriers_relay_tcp_and_fragmented_udp() { for carrier in ["tcp", "udp"] { @@ -218,30 +274,40 @@ async fn mux_symmetric_carriers_relay_tcp_and_fragmented_udp() { #[tokio::test] async fn mux_full_duplex_tcp_exceeds_each_direction_credit_window() { - const DIRECTION_BYTES: usize = 3 * 1024 * 1024; + full_duplex_exceeds_each_direction_credit_window("tcp").await; +} - for carrier in ["tcp", "udp"] { +#[tokio::test] +async fn quic_full_duplex_tcp_exceeds_each_direction_credit_window() { + full_duplex_exceeds_each_direction_credit_window("udp").await; +} + +async fn full_duplex_exceeds_each_direction_credit_window(carrier: &str) { + // The throughput profile grants 16 MiB per stream. Cross that boundary in + // both directions so progress depends on returning Mux credit. + const DIRECTION_BYTES: usize = 20 * 1024 * 1024; + + { + let progress = Arc::new(std::array::from_fn::<_, 4, _>(|_| { + std::sync::atomic::AtomicUsize::new(0) + })); let target = TcpListener::bind("127.0.0.1:0").await.unwrap(); let target_address = target.local_addr().unwrap(); + let target_progress = progress.clone(); let target_task = tokio::spawn(async move { let (stream, _) = target.accept().await.unwrap(); + stream.set_nodelay(true).unwrap(); let (mut reader, mut writer) = stream.into_split(); - let upload = async { - let mut received = vec![0_u8; DIRECTION_BYTES]; - reader.read_exact(&mut received).await.unwrap(); - assert!(received.iter().all(|byte| *byte == 0xa5)); - }; - let download = async { - writer - .write_all(&vec![0x5a; DIRECTION_BYTES]) - .await - .unwrap(); - }; + let upload = + read_full_duplex_payload(&mut reader, DIRECTION_BYTES, 0xa5, &target_progress[1]); + let download = + write_full_duplex_payload(&mut writer, DIRECTION_BYTES, 0x5a, &target_progress[2]); tokio::join!(upload, download); }); let runtime = start_runtime(carrier, carrier, 1).await; - timeout(TEST_TIMEOUT, async { + let result = timeout(FULL_DUPLEX_TIMEOUT, async { let mut stream = TcpStream::connect(runtime.socks).await.unwrap(); + stream.set_nodelay(true).unwrap(); negotiate_socks(&mut stream).await; stream .write_all(&ip_request(1, target_address)) @@ -249,31 +315,73 @@ async fn mux_full_duplex_tcp_exceeds_each_direction_credit_window() { .unwrap(); read_ipv4_reply(&mut stream).await; let (mut reader, mut writer) = stream.into_split(); - let upload = async { - writer - .write_all(&vec![0xa5; DIRECTION_BYTES]) - .await - .unwrap(); - }; - let download = async { - let mut received = vec![0_u8; DIRECTION_BYTES]; - reader.read_exact(&mut received).await.unwrap(); - assert!(received.iter().all(|byte| *byte == 0x5a)); - }; + let upload = + write_full_duplex_payload(&mut writer, DIRECTION_BYTES, 0xa5, &progress[0]); + let download = + read_full_duplex_payload(&mut reader, DIRECTION_BYTES, 0x5a, &progress[3]); tokio::join!(upload, download); }) - .await - .unwrap(); + .await; + if result.is_err() { + target_task.abort(); + runtime.stop().await; + panic!( + "{carrier} full-duplex timeout: client sent={}, target received={}, target sent={}, client received={}, expected={DIRECTION_BYTES}", + progress[0].load(Ordering::Relaxed), + progress[1].load(Ordering::Relaxed), + progress[2].load(Ordering::Relaxed), + progress[3].load(Ordering::Relaxed) + ); + } target_task.await.unwrap(); runtime.stop().await; } } +async fn write_full_duplex_payload( + writer: &mut (impl tokio::io::AsyncWrite + Unpin), + total: usize, + byte: u8, + progress: &std::sync::atomic::AtomicUsize, +) { + let chunk = [byte; 32 * 1024]; + let mut sent = 0; + while sent < total { + let count = writer + .write(&chunk[..chunk.len().min(total - sent)]) + .await + .unwrap(); + assert_ne!(count, 0, "full-duplex write made no progress"); + sent += count; + progress.store(sent, Ordering::Relaxed); + } +} + +async fn read_full_duplex_payload( + reader: &mut (impl tokio::io::AsyncRead + Unpin), + total: usize, + byte: u8, + progress: &std::sync::atomic::AtomicUsize, +) { + let mut chunk = [0; 32 * 1024]; + let mut received = 0; + while received < total { + let capacity = chunk.len().min(total - received); + let count = reader.read(&mut chunk[..capacity]).await.unwrap(); + assert_ne!(count, 0, "full-duplex stream ended early"); + assert!(chunk[..count].iter().all(|value| *value == byte)); + received += count; + progress.store(received, Ordering::Relaxed); + } +} + #[tokio::test] -async fn mux_fifth_active_tcp_flow_opens_a_second_shard() { - const FLOW_COUNT: usize = 5; +async fn mux_hundreds_of_tcp_flows_share_at_most_eight_carriers() { + const FLOW_COUNT: usize = 300; - let target = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let target = tokio::net::TcpSocket::new_v4().unwrap(); + target.bind("127.0.0.1:0".parse().unwrap()).unwrap(); + let target = target.listen(1024).unwrap(); let target_address = target.local_addr().unwrap(); let target_shutdown = CancellationToken::new(); let target_child_shutdown = target_shutdown.clone(); @@ -289,25 +397,37 @@ async fn mux_fifth_active_tcp_flow_opens_a_second_shard() { let flows = timeout(TEST_TIMEOUT, async { let mut flows = Vec::with_capacity(FLOW_COUNT); + let mut opening = tokio::task::JoinSet::new(); for _ in 0..FLOW_COUNT { - let mut flow = TcpStream::connect(runtime.socks).await.unwrap(); - negotiate_socks(&mut flow).await; - flow.write_all(&ip_request(1, target_address)) - .await - .unwrap(); - read_ipv4_reply(&mut flow).await; - flows.push(flow); + // Exercise concurrent cold admission without overflowing the OS + // target listener's SYN backlog when the full suite runs in parallel. + if opening.len() == 32 { + flows.push(opening.join_next().await.unwrap().unwrap()); + } + let socks = runtime.socks; + opening.spawn(async move { + let mut flow = TcpStream::connect(socks).await.unwrap(); + negotiate_socks(&mut flow).await; + flow.write_all(&ip_request(1, target_address)) + .await + .unwrap(); + read_ipv4_reply(&mut flow).await; + flow + }); } - while runtime.portal_stats.link_tcp.load(Ordering::Relaxed) != 2 { - tokio::task::yield_now().await; + while let Some(flow) = opening.join_next().await { + flows.push(flow.unwrap()); } flows }) .await .unwrap(); - assert_eq!(runtime.portal_stats.link_tcp.load(Ordering::Relaxed), 2); - assert_eq!(runtime.portal_stats.tcp_active.load(Ordering::Relaxed), 5); + assert_eq!(runtime.portal_stats.link_tcp.load(Ordering::Relaxed), 8); + assert_eq!( + runtime.portal_stats.tcp_active.load(Ordering::Relaxed), + FLOW_COUNT as i32 + ); drop(flows); target_shutdown.cancel(); target_task.await.unwrap(); @@ -316,10 +436,11 @@ async fn mux_fifth_active_tcp_flow_opens_a_second_shard() { async fn start_chain_runtime(up: &str, down: &str) -> ChainRuntime { let logger = || Logger::new(LogLevel::None, false); - let (origin_port, origin_tcp_reservation, origin_udp_reservation) = reserve_mixed_port().await; + let (origin_tcp_port, origin_tcp_reservation) = reserve_tcp_port().await; + let (origin_udp_port, origin_udp_reservation) = reserve_udp_port_except(origin_tcp_port).await; let origin = Portal::new( Url::parse(&format!( - "portal://origin-secret@127.0.0.1:{origin_port}?log=none&net=mix" + "portal://origin-secret@127.0.0.1/tcp:{origin_tcp_port}/udp:{origin_udp_port}?log=none" )) .unwrap(), logger(), @@ -333,7 +454,7 @@ async fn start_chain_runtime(up: &str, down: &str) -> ChainRuntime { let (relay_port, relay_tcp_reservation, relay_udp_reservation) = reserve_mixed_port().await; let relay = Portal::new( Url::parse(&format!( - "portal://relay-secret@127.0.0.1:{relay_port}?log=none&net=mix&next=origin-secret@127.0.0.1:{origin_port}&up={up}&down={down}&mux=1" + "portal://relay-secret@127.0.0.1:{relay_port}?log=none&next=origin-secret@127.0.0.1/tcp:{origin_tcp_port}/udp:{origin_udp_port}&up={up}&down={down}&mux=1" )) .unwrap(), logger(), @@ -751,3 +872,108 @@ async fn native_portal_chain_relays_tcp_and_udp_for_every_upstream_route_policy( #[path = "vector/chain_failure.rs"] mod chain_failure; + +#[tokio::test] +async fn single_carrier_defaults_relay_tcp4_to_udp6() { + let origin_reservation = UdpSocket::bind("[::1]:0").await.unwrap(); + let origin_port = origin_reservation.local_addr().unwrap().port(); + let origin = Portal::new( + Url::parse(&format!( + "portal://origin-secret@[::1]/udp6:{origin_port}?log=none" + )) + .unwrap(), + Logger::new(LogLevel::None, false), + ) + .unwrap(); + assert!(origin.listen_tcp_listeners().unwrap().is_empty()); + drop(origin_reservation); + let endpoint = origin.listen_endpoints().unwrap().pop().unwrap(); + + let (relay_port, reservation) = reserve_tcp_port().await; + let relay = Portal::new( + Url::parse(&format!("portal://relay-secret@127.0.0.1/tcp4:{relay_port}?next=origin-secret@[::1]/udp6:{origin_port}&log=none")).unwrap(), + Logger::new(LogLevel::None, false), + ).unwrap(); + assert!(relay.listen_endpoints().unwrap().is_empty()); + drop(reservation); + let listener = relay.listen_tcp_listeners().unwrap().pop().unwrap(); + let shutdown = CancellationToken::new(); + let portal_tasks = vec![ + tokio::spawn(crate::portal::listener::accept_endpoint_loop( + origin.inner.clone(), + endpoint.clone(), + shutdown.clone(), + shutdown.clone(), + )), + tokio::spawn(crate::portal::listener::accept_tcp_loop( + relay.inner.clone(), + listener, + shutdown.clone(), + shutdown.clone(), + )), + ]; + let (socks_port, reservation) = reserve_tcp_port().await; + let vector = Vector::new( + Url::parse(&format!("vector://relay-secret@127.0.0.1/tcp4:{relay_port}?socks=127.0.0.1:{socks_port}&log=none")).unwrap(), + Logger::new(LogLevel::None, false), + ).unwrap(); + drop(reservation); + let vector_task = tokio::spawn(vector.run()); + let socks = SocketAddr::from(([127, 0, 0, 1], socks_port)); + wait_for_socks(socks).await; + let runtime = ChainRuntime { + shutdown, + endpoints: vec![endpoint], + portal_tasks, + vector_task, + relay, + socks, + }; + + timeout(TEST_TIMEOUT, async { + let target = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = target.local_addr().unwrap(); + let echo = tokio::spawn(async move { + let (mut stream, _) = target.accept().await.unwrap(); + let mut packet = [0; 4]; + stream.read_exact(&mut packet).await.unwrap(); + stream.write_all(&packet).await.unwrap(); + }); + let mut stream = TcpStream::connect(socks).await.unwrap(); + negotiate_socks(&mut stream).await; + stream.write_all(&ip_request(1, address)).await.unwrap(); + read_ipv4_reply(&mut stream).await; + stream.write_all(b"ping").await.unwrap(); + let mut packet = [0; 4]; + stream.read_exact(&mut packet).await.unwrap(); + assert_eq!(&packet, b"ping"); + echo.await.unwrap(); + + let target = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let address = target.local_addr().unwrap(); + let echo = tokio::spawn(async move { + let mut packet = [0; 64]; + let (length, peer) = target.recv_from(&mut packet).await.unwrap(); + target.send_to(&packet[..length], peer).await.unwrap(); + }); + let mut control = TcpStream::connect(socks).await.unwrap(); + negotiate_socks(&mut control).await; + control + .write_all(&ip_request(3, "0.0.0.0:0".parse().unwrap())) + .await + .unwrap(); + let udp_relay = read_ipv4_reply(&mut control).await; + let client = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let mut packet = vec![0, 0, 0]; + packet.extend_from_slice(&ip_request(0, address)[3..]); + packet.extend_from_slice(b"ping"); + client.send_to(&packet, udp_relay).await.unwrap(); + let mut reply = [0; 64]; + let (length, _) = client.recv_from(&mut reply).await.unwrap(); + assert_eq!(&reply[10..length], b"ping"); + echo.await.unwrap(); + }) + .await + .unwrap(); + runtime.stop().await; +} diff --git a/src/tests/portal/listener.rs b/src/tests/portal/listener.rs new file mode 100644 index 0000000..0b595b9 --- /dev/null +++ b/src/tests/portal/listener.rs @@ -0,0 +1,15 @@ +use super::*; + +#[tokio::test] +async fn ipv6_sockets_are_v6_only_and_allow_ipv4_on_the_same_port() { + let udp = bind_quic_socket("[::]:0".parse().unwrap()).unwrap(); + assert!(socket2::SockRef::from(&udp).only_v6().unwrap()); + let udp4 = std::net::UdpSocket::bind(("0.0.0.0", udp.local_addr().unwrap().port())).unwrap(); + let tcp = listen_tcp("[::]:0".parse().unwrap()) + .unwrap() + .into_std() + .unwrap(); + assert!(socket2::SockRef::from(&tcp).only_v6().unwrap()); + let tcp4 = std::net::TcpListener::bind(("0.0.0.0", tcp.local_addr().unwrap().port())).unwrap(); + drop((udp, udp4, tcp, tcp4)); +} diff --git a/src/tests/portal/pairing.rs b/src/tests/portal/pairing.rs index 1592b64..339eed0 100644 --- a/src/tests/portal/pairing.rs +++ b/src/tests/portal/pairing.rs @@ -9,15 +9,13 @@ use crate::protocol::{ read_flow_result, }; use crate::transport::Stats; -use std::collections::HashMap; use std::pin::Pin; use std::sync::Arc; -use std::sync::Mutex as StdMutex; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::task::{Context, Poll}; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use tokio::sync::{Mutex, mpsc}; +use tokio::sync::mpsc; impl PairingRegistry { fn is_accepting(&self) -> bool { @@ -25,21 +23,8 @@ impl PairingRegistry { } } -fn registry(max_udp_flows: usize, timeout: Duration) -> Arc { - Arc::new(PairingRegistry { - tcp: Mutex::new(HashMap::new()), - udp: Mutex::new(HashMap::new()), - links: StdMutex::new(HashMap::new()), - claims: StdMutex::new(HashMap::new()), - rejections: StdMutex::new(HashMap::new()), - accepting: AtomicBool::new(true), - next_quic_generation: AtomicU64::new(1), - next_epoch: AtomicU64::new(1), - max_pending: 16, - timeout, - max_tcp_flows: 16, - max_udp_flows, - }) +fn registry(timeout: Duration) -> Arc { + Arc::new(PairingRegistry::new(timeout)) } fn header( @@ -66,7 +51,7 @@ fn target(value: &str) -> Target { fn path(label: &str) -> LinkPath { LinkPath { peer: format!("{label}.client:1234"), - local: "portal.test:2077".into(), + local: "portal.test:2000".into(), } } @@ -78,17 +63,6 @@ fn quic_half(label: &str, generation: u64) -> LinkHalf { LinkHalf::quic(path(label), generation) } -fn available_udp_permits(registry: &PairingRegistry, session_id: SessionId) -> usize { - registry - .links - .lock() - .expect("link registry poisoned") - .get(&session_id) - .expect("registered session") - .udp_flow_budget - .available_permits() -} - struct PendingWriter; trait PairingResultExt { diff --git a/src/tests/portal/pairing/lifecycle.rs b/src/tests/portal/pairing/lifecycle.rs index 243629b..dafeac3 100644 --- a/src/tests/portal/pairing/lifecycle.rs +++ b/src/tests/portal/pairing/lifecycle.rs @@ -4,92 +4,44 @@ use super::*; #[tokio::test] -async fn tcp_flow_limit_is_scoped_to_each_authenticated_session() { - let mut registry = registry(8, Duration::from_secs(30)); - Arc::get_mut(&mut registry).unwrap().max_tcp_flows = 1; - let stats = Arc::new(Stats::default()); - let first_session = [0x31; SESSION_ID_LEN]; - let second_session = [0x32; SESSION_ID_LEN]; - let _first_guard = registry.register_tcp_link(first_session, stats.clone()); - let _second_guard = registry.register_tcp_link(second_session, stats); - - let (first_io, _) = tokio::io::duplex(64); - let (first_down, _) = tokio::io::duplex(64); - let first = registry - .submit_tcp( - first_session, - header( - FlowRole::Duplex, - 1, - FlowKind::Tcp, - Carrier::TlsTcp, - Carrier::TlsTcp, - ), - Some(target("target.test:443")), - tcp_half("first"), - Some(Box::pin(first_io)), - Some(Box::pin(first_down)), - None, - ) - .await - .unwrap() - .unwrap(); - - let (excess_io, _) = tokio::io::duplex(64); - let (excess_down, mut excess_peer) = tokio::io::duplex(64); - let error = registry - .submit_tcp( - first_session, - header( - FlowRole::Duplex, - 2, - FlowKind::Tcp, - Carrier::TlsTcp, - Carrier::TlsTcp, - ), - Some(target("target.test:443")), - tcp_half("excess"), - Some(Box::pin(excess_io)), - Some(Box::pin(excess_down)), - None, - ) - .await - .unwrap_pairing_error(); - assert_eq!(error.code(), FlowErrorCode::FlowLimit); - assert_eq!( - read_flow_result(&mut excess_peer).await.unwrap(), - FlowResult::Reject(FlowErrorCode::FlowLimit) - ); - - let (other_io, _) = tokio::io::duplex(64); - let (other_down, _) = tokio::io::duplex(64); - let other = registry - .submit_tcp( - second_session, - header( - FlowRole::Duplex, - 1, - FlowKind::Tcp, - Carrier::TlsTcp, - Carrier::TlsTcp, - ), - Some(target("target.test:443")), - tcp_half("other"), - Some(Box::pin(other_io)), - Some(Box::pin(other_down)), - None, - ) - .await - .unwrap(); - assert!(other.is_some()); - - drop(first); - drop(other); +async fn tcp_sessions_admit_more_than_the_former_flow_limit() { + let registry = registry(Duration::from_secs(30)); + let session = [0x31; SESSION_ID_LEN]; + let _guard = registry.register_tcp_link(session, Arc::new(Stats::default())); + let mut held = Vec::new(); + for id in 1..=2048 { + let (up, _) = tokio::io::duplex(64); + let (down, _) = tokio::io::duplex(64); + held.push( + registry + .submit_tcp( + session, + header( + FlowRole::Duplex, + id, + FlowKind::Tcp, + Carrier::TlsTcp, + Carrier::TlsTcp, + ), + Some(target("target.test:443")), + tcp_half("live"), + Some(Box::pin(up)), + Some(Box::pin(down)), + None, + ) + .await + .unwrap() + .unwrap(), + ); + } + assert_eq!(registry.claims.lock().unwrap().len(), 2048); + drop(held); + assert!(registry.claims.lock().unwrap().is_empty()); } #[tokio::test] async fn drain_rejects_pending_and_new_flows_but_preserves_active_claims() { - let registry = registry(8, Duration::from_secs(30)); + let registry = registry(Duration::from_secs(30)); let stats = Arc::new(Stats::default()); let session_id = [0x5a; SESSION_ID_LEN]; let _tcp_guard = registry.register_tcp_link(session_id, stats); @@ -201,7 +153,7 @@ async fn drain_rejects_pending_and_new_flows_but_preserves_active_claims() { #[tokio::test] async fn cancel_all_cancels_active_flows_without_waiting_for_pending_writer() { - let registry = registry(8, Duration::from_secs(60)); + let registry = registry(Duration::from_secs(60)); let stats = Arc::new(Stats::default()); let session_id = [5; SESSION_ID_LEN]; let tcp_guard = registry.register_tcp_link(session_id, stats.clone()); @@ -285,3 +237,142 @@ async fn cancel_all_cancels_active_flows_without_waiting_for_pending_writer() { drop(quic_guard); drop(tcp_guard); } + +#[tokio::test] +async fn pending_pairs_exceed_former_limit_and_release_quic_credit_on_drain() { + let registry = registry(Duration::from_secs(30)); + let session = [0x32; SESSION_ID_LEN]; + let _guard = registry.register_tcp_link(session, Arc::new(Stats::default())); + let mut peers = Vec::new(); + for id in 1..=2048 { + let (up, peer) = tokio::io::duplex(64); + peers.push(peer); + assert!( + registry + .submit_tcp( + session, + header( + FlowRole::Open, + id, + FlowKind::Tcp, + Carrier::TlsTcp, + Carrier::Quic + ), + Some(target("target.test:443")), + tcp_half("pending"), + Some(Box::pin(up)), + None, + None, + ) + .await + .unwrap() + .is_none() + ); + } + assert_eq!(registry.claims.lock().unwrap().len(), 2048); + assert_eq!(registry.quic_stream_credit(session).into_inner(), 2560); + registry.begin_drain().await; + assert!(registry.claims.lock().unwrap().is_empty()); + assert_eq!(registry.quic_stream_credit(session).into_inner(), 64); +} + +#[tokio::test] +async fn session_claim_admission_is_bounded_and_reusable() { + let registry = registry(Duration::from_secs(30)); + let session = [0x33; SESSION_ID_LEN]; + let _guard = registry.register_tcp_link(session, Arc::new(Stats::default())); + let admission = registry.session_flow_admission(session).unwrap(); + let _held = admission + .clone() + .try_acquire_many_owned((SESSION_FLOW_RESOURCE_LIMIT - 1) as u32) + .unwrap(); + + let first = submit_resource_test_flow(®istry, session, 1) + .await + .unwrap() + .unwrap(); + let error = submit_resource_test_flow(®istry, session, 2) + .await + .unwrap_pairing_error(); + assert_eq!(error.code(), FlowErrorCode::FlowLimit); + + drop(first); + assert!( + submit_resource_test_flow(®istry, session, 3) + .await + .unwrap() + .is_some() + ); +} + +#[tokio::test] +async fn portal_claim_admission_is_bounded_and_reusable() { + let registry = registry(Duration::from_secs(30)); + let first_session = [0x34; SESSION_ID_LEN]; + let second_session = [0x35; SESSION_ID_LEN]; + let stats = Arc::new(Stats::default()); + let _first_guard = registry.register_tcp_link(first_session, stats.clone()); + let _second_guard = registry.register_tcp_link(second_session, stats); + let _held = registry + .claim_admission + .clone() + .try_acquire_many_owned((PORTAL_FLOW_RESOURCE_LIMIT - 1) as u32) + .unwrap(); + + let first = submit_resource_test_flow(®istry, first_session, 1) + .await + .unwrap() + .unwrap(); + let error = submit_resource_test_flow(®istry, second_session, 1) + .await + .unwrap_pairing_error(); + assert_eq!(error.code(), FlowErrorCode::FlowLimit); + + drop(first); + assert!( + submit_resource_test_flow(®istry, second_session, 2) + .await + .unwrap() + .is_some() + ); +} + +#[test] +fn quic_stream_credit_is_clamped_to_the_session_claim_budget() { + let registry = registry(Duration::from_secs(30)); + let session = [0x36; SESSION_ID_LEN]; + let _guard = registry.register_tcp_link(session, Arc::new(Stats::default())); + let counter = registry.quic_flow_counter(session).unwrap(); + counter.store(SESSION_FLOW_RESOURCE_LIMIT * 2, Ordering::Relaxed); + + assert_eq!( + registry.quic_stream_credit(session).into_inner(), + SESSION_FLOW_RESOURCE_LIMIT as u64 + ); +} + +async fn submit_resource_test_flow( + registry: &Arc, + session: [u8; SESSION_ID_LEN], + flow_id: u32, +) -> Result, PairingError> { + let (uplink, _uplink_peer) = tokio::io::duplex(64); + let (downlink, _downlink_peer) = tokio::io::duplex(64); + registry + .submit_tcp( + session, + header( + FlowRole::Duplex, + flow_id, + FlowKind::Tcp, + Carrier::TlsTcp, + Carrier::TlsTcp, + ), + Some(target("target.test:443")), + tcp_half("resource"), + Some(Box::pin(uplink)), + Some(Box::pin(downlink)), + None, + ) + .await +} diff --git a/src/tests/portal/pairing/rejection.rs b/src/tests/portal/pairing/rejection.rs index 13c6506..62a3b00 100644 --- a/src/tests/portal/pairing/rejection.rs +++ b/src/tests/portal/pairing/rejection.rs @@ -5,7 +5,7 @@ use super::*; #[tokio::test] async fn initially_stale_udp_open_leaves_exact_rejection_for_uot_attach() { - let registry = registry(8, Duration::from_secs(30)); + let registry = registry(Duration::from_secs(30)); let stats = Arc::new(Stats::default()); let session_id = [7; SESSION_ID_LEN]; let tcp_guard = registry.register_tcp_link(session_id, stats.clone()); @@ -83,7 +83,7 @@ async fn initially_stale_udp_open_leaves_exact_rejection_for_uot_attach() { #[tokio::test] async fn late_attach_receives_original_open_pair_timeout() { - let registry = registry(8, Duration::from_millis(10)); + let registry = registry(Duration::from_millis(10)); let stats = Arc::new(Stats::default()); let tcp_session = [8; SESSION_ID_LEN]; @@ -213,7 +213,7 @@ async fn late_attach_receives_original_open_pair_timeout() { #[tokio::test] async fn tombstones_deliver_exact_reject_on_selected_downlink() { - let registry = registry(8, Duration::from_secs(30)); + let registry = registry(Duration::from_secs(30)); let stats = Arc::new(Stats::default()); let tcp_session = [3; SESSION_ID_LEN]; diff --git a/src/tests/portal/pairing/replacement.rs b/src/tests/portal/pairing/replacement.rs index 186bb08..c9d23d1 100644 --- a/src/tests/portal/pairing/replacement.rs +++ b/src/tests/portal/pairing/replacement.rs @@ -5,7 +5,7 @@ use super::*; #[tokio::test] async fn old_quic_guard_does_not_cancel_flow_on_replacement_generation() { - let registry = registry(8, Duration::from_secs(30)); + let registry = registry(Duration::from_secs(30)); let stats = Arc::new(Stats::default()); let session_id = [1; SESSION_ID_LEN]; let _tcp_guard = registry.register_tcp_link(session_id, stats.clone()); @@ -81,7 +81,7 @@ async fn old_quic_guard_does_not_cancel_flow_on_replacement_generation() { #[tokio::test] async fn quic_replacement_immediately_rejects_pending_split_flows() { - let registry = registry(8, Duration::from_secs(30)); + let registry = registry(Duration::from_secs(30)); let stats = Arc::new(Stats::default()); let session_id = [0x33; SESSION_ID_LEN]; let _tcp_guard = registry.register_tcp_link(session_id, stats.clone()); @@ -185,7 +185,7 @@ async fn quic_replacement_immediately_rejects_pending_split_flows() { #[tokio::test] async fn stale_open_after_map_lock_leaves_exact_rejection_for_tcp_attach() { - let registry = registry(8, Duration::from_secs(30)); + let registry = registry(Duration::from_secs(30)); let stats = Arc::new(Stats::default()); let session_id = [2; SESSION_ID_LEN]; let tcp_guard = registry.register_tcp_link(session_id, stats.clone()); diff --git a/src/tests/portal/pairing/udp.rs b/src/tests/portal/pairing/udp.rs index 28bba6f..4f957ac 100644 --- a/src/tests/portal/pairing/udp.rs +++ b/src/tests/portal/pairing/udp.rs @@ -4,109 +4,42 @@ use super::*; #[tokio::test] -async fn udp_permit_is_shared_by_quic_and_uot_and_released_by_cancel() { - let registry = registry(1, Duration::from_secs(60)); - let stats = Arc::new(Stats::default()); - let session_id = [6; SESSION_ID_LEN]; - let tcp_guard = registry.register_tcp_link(session_id, stats.clone()); - let quic_guard = registry - .register_quic_link( - session_id, - stats, - tokio_util::sync::CancellationToken::new(), - ) - .await; - - let (_datagram_tx, datagram_rx) = mpsc::channel(1); - assert!( - registry - .submit_udp( - session_id, - header( - FlowRole::Open, - 13, - FlowKind::Udp, - Carrier::Quic, - Carrier::TlsTcp, - ), - Some(target("target.test:53")), - quic_half("udp-up", quic_guard.quic_generation()), - UdpHalf::Uplink { - uplink: UdpUp::Quic(QuicUdpReceiver::new_without_barrier( - datagram_rx, - Arc::new(AtomicBool::new(false)), - || {}, - )), - }, - ) - .await - .unwrap() - .is_none() - ); - assert_eq!(available_udp_permits(®istry, session_id), 0); - - let (rejected_uplink, _rejected_uplink_peer) = tokio::io::duplex(64); - let (rejected_downlink, mut rejected_peer) = tokio::io::duplex(64); - let rejected = registry - .submit_udp( - session_id, - header( - FlowRole::Duplex, - 14, - FlowKind::Udp, - Carrier::TlsTcp, - Carrier::TlsTcp, - ), - Some(target("target.test:53")), - tcp_half("uot-limited"), - UdpHalf::Duplex { - uplink: UdpUp::TlsTcp(Box::pin(rejected_uplink)), - downlink: UdpDown::TlsTcp { - writer: Box::pin(rejected_downlink), - liveness: None, - }, - }, - ) - .await - .unwrap_pairing_error(); - assert_eq!(rejected.code(), FlowErrorCode::FlowLimit); - assert_eq!( - read_flow_result(&mut rejected_peer).await.unwrap(), - FlowResult::Reject(FlowErrorCode::FlowLimit) - ); - - registry.cancel_udp(session_id, 13).await; - assert_eq!(available_udp_permits(®istry, session_id), 1); - - let (uot_uplink, _uot_uplink_peer) = tokio::io::duplex(64); - let (uot_downlink, _uot_downlink_peer) = tokio::io::duplex(64); - let paired = registry - .submit_udp( - session_id, - header( - FlowRole::Duplex, - 14, - FlowKind::Udp, - Carrier::TlsTcp, - Carrier::TlsTcp, - ), - Some(target("target.test:53")), - tcp_half("uot-accepted"), - UdpHalf::Duplex { - uplink: UdpUp::TlsTcp(Box::pin(uot_uplink)), - downlink: UdpDown::TlsTcp { - writer: Box::pin(uot_downlink), - liveness: None, - }, - }, - ) - .await - .unwrap() - .expect("released permit should admit UoT flow"); - assert_eq!(available_udp_permits(®istry, session_id), 0); - drop(paired); - assert_eq!(available_udp_permits(®istry, session_id), 1); - - drop(quic_guard); - drop(tcp_guard); +async fn uot_flows_exceed_the_former_udp_limit_without_reserving_quic_credit() { + let registry = registry(Duration::from_secs(60)); + let session = [6; SESSION_ID_LEN]; + let _guard = registry.register_tcp_link(session, Arc::new(Stats::default())); + let mut held = Vec::new(); + for id in 1..=512 { + let (up, _) = tokio::io::duplex(64); + let (down, _) = tokio::io::duplex(64); + held.push( + registry + .submit_udp( + session, + header( + FlowRole::Duplex, + id, + FlowKind::Udp, + Carrier::TlsTcp, + Carrier::TlsTcp, + ), + Some(target("target.test:53")), + tcp_half("uot"), + UdpHalf::Duplex { + uplink: UdpUp::TlsTcp(Box::pin(up)), + downlink: UdpDown::TlsTcp { + writer: Box::pin(down), + liveness: None, + }, + }, + ) + .await + .unwrap() + .unwrap(), + ); + } + assert_eq!(registry.claims.lock().unwrap().len(), 512); + assert_eq!(registry.quic_stream_credit(session).into_inner(), 64); + drop(held); + assert!(registry.claims.lock().unwrap().is_empty()); } diff --git a/src/tests/portal/runtime.rs b/src/tests/portal/runtime.rs new file mode 100644 index 0000000..a8e7ac8 --- /dev/null +++ b/src/tests/portal/runtime.rs @@ -0,0 +1,149 @@ +use super::{bind_carrier, io_error_is_family_unavailable}; +use std::io::{Error, ErrorKind}; +use std::net::{SocketAddr, TcpListener}; + +#[tokio::test] +async fn dns_listener_binds_every_unique_resolved_address() { + use std::net::ToSocketAddrs; + let reservation = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = reservation.local_addr().unwrap().port(); + let portal = super::Portal::new( + url::Url::parse(&format!("portal://secret@localhost/tcp:{port}")).unwrap(), + crate::common::Logger::new(crate::common::LogLevel::None, false), + ) + .unwrap(); + let mut expected = ("localhost", port) + .to_socket_addrs() + .unwrap() + .collect::>(); + expected.sort_unstable(); + expected.dedup(); + drop(reservation); + let listeners = portal.listen_tcp_listeners().unwrap(); + let mut actual = listeners + .iter() + .map(|listener| listener.local_addr().unwrap()) + .collect::>(); + actual.sort_unstable(); + assert_eq!(actual, expected); +} + +#[tokio::test] +async fn tcp_startup_failure_releases_already_opened_quic_socket() { + let occupied = TcpListener::bind("127.0.0.1:0").unwrap(); + let tcp_port = occupied.local_addr().unwrap().port(); + let reservation = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let address = reservation.local_addr().unwrap(); + let portal = super::Portal::new( + url::Url::parse(&format!( + "portal://secret@127.0.0.1/tcp4:{tcp_port}/udp4:{}", + address.port() + )) + .unwrap(), + crate::common::Logger::new(crate::common::LogLevel::None, false), + ) + .unwrap(); + drop(reservation); + assert!(portal.run().await.is_err()); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + match std::net::UdpSocket::bind(address) { + Ok(socket) => { + drop(socket); + break; + } + Err(error) if error.kind() == ErrorKind::AddrInUse => { + tokio::task::yield_now().await + } + Err(error) => panic!("unexpected rebind error: {error}"), + } + } + }) + .await + .expect("QUIC socket leaked after TCP startup failure"); +} + +#[test] +fn injected_family_failure_warns_only_when_degradation_is_allowed() { + let addresses: [SocketAddr; 2] = [ + "0.0.0.0:2000".parse().unwrap(), + "[::]:2000".parse().unwrap(), + ]; + for allow in [false, true] { + let mut warnings = Vec::new(); + let result = bind_carrier( + &addresses, + allow, + |addr| { + if addr.is_ipv6() { + Err( + Error::new(ErrorKind::AddrNotAvailable, "injected unavailable family") + .into(), + ) + } else { + Ok(addr) + } + }, + |addr, _| warnings.push(addr), + ); + assert_eq!(result.is_ok(), allow); + assert_eq!(warnings.len(), usize::from(allow)); + if let Ok(bound) = result { + assert_eq!(bound, [addresses[0]]); + } + } + assert!( + bind_carrier::<()>( + &addresses, + true, + |_| Err(Error::new(ErrorKind::AddrNotAvailable, "unavailable").into()), + |_, _| {} + ) + .is_err() + ); +} + +#[test] +fn fatal_bind_failure_releases_preceding_socket_even_with_degradation_enabled() { + for kind in [ErrorKind::AddrInUse, ErrorKind::PermissionDenied] { + let mut bound = None; + let addresses = ["127.0.0.1:0".parse().unwrap(); 2]; + let result = bind_carrier( + &addresses, + true, + |addr| { + if bound.is_some() { + return Err(Error::new(kind, "injected fatal failure").into()); + } + let socket = TcpListener::bind(addr)?; + bound = Some(socket.local_addr()?); + Ok(socket) + }, + |_, _| panic!("fatal errors must not degrade"), + ); + assert!(result.is_err()); + let rebound = TcpListener::bind(bound.unwrap()).expect("preceding listener leaked"); + drop(rebound); + } +} + +#[test] +fn classifies_portable_and_platform_family_errors() { + assert!(io_error_is_family_unavailable(&std::io::Error::new( + std::io::ErrorKind::Unsupported, + "family unavailable", + ))); + #[cfg(unix)] + let codes = [libc::EAFNOSUPPORT]; + #[cfg(windows)] + let codes = [10047]; + for code in codes { + assert!(io_error_is_family_unavailable( + &std::io::Error::from_raw_os_error(code) + )); + } + assert!(!io_error_is_family_unavailable(&std::io::Error::new( + std::io::ErrorKind::AddrInUse, + "occupied", + ))); +} diff --git a/src/tests/protocol/auth.rs b/src/tests/protocol/auth.rs index 7796939..d938669 100644 --- a/src/tests/protocol/auth.rs +++ b/src/tests/protocol/auth.rs @@ -17,7 +17,7 @@ fn hkdf_and_auth_frames_match_fixed_vectors() { let key = derive_auth_key(b"secret"); assert_eq!( hex(&key), - "1076221669fa28bcf70aa8545bddd6f760dcefbe279c3f38a5ff5d925708f867" + "8de7e08dd22134ac5acc57902658a36b7f6f9d219987ed49b934a7529a4d24c5" ); assert_eq!( hex(&encode_auth_frame( @@ -26,7 +26,7 @@ fn hkdf_and_auth_frames_match_fixed_vectors() { &EXPORTER, SESSION )), - "000102030405060708090a0b0c0d0e0f24a4c0d5f8946b65bcf270ed6e1c3dec" + "000102030405060708090a0b0c0d0e0f91f3033378b001f0de171717c027be00" ); assert_eq!( hex(&encode_auth_frame( @@ -35,7 +35,7 @@ fn hkdf_and_auth_frames_match_fixed_vectors() { &EXPORTER, SESSION )), - "000102030405060708090a0b0c0d0e0f8176b984db64a1e2c811e751d955b635" + "000102030405060708090a0b0c0d0e0f97d0be56bda8a0ee7596775b35efbf68" ); } diff --git a/src/tests/protocol/datagram.rs b/src/tests/protocol/datagram.rs index 62f392a..d6a69ab 100644 --- a/src/tests/protocol/datagram.rs +++ b/src/tests/protocol/datagram.rs @@ -3,20 +3,117 @@ use std::time::{Duration, Instant}; +use crate::protocol::MAX_FLOW_ID; + use super::*; #[test] -fn normal_data_and_close_have_exact_five_byte_headers() { +fn packed_header_preserves_maximum_ids_and_all_type_bits() { + let data = encode_udp_data(MAX_FLOW_ID, &[]).unwrap(); + assert_eq!(data, [0x3f, 0xff, 0xff, 0xff]); assert_eq!( - encode_udp_data(0x0102_0304, &[0xaa, 0xbb]).unwrap(), - [0x00, 1, 2, 3, 4, 0xaa, 0xbb] + decode_udp_frame(&data).unwrap(), + UdpFrame::Data { + flow_id: MAX_FLOW_ID, + payload: &[] + } ); + let close = encode_udp_close(MAX_FLOW_ID).unwrap(); + assert_eq!(close, [0xbf, 0xff, 0xff, 0xff]); + assert_eq!( + decode_udp_frame(&close).unwrap(), + UdpFrame::Close { + flow_id: MAX_FLOW_ID + } + ); + let mut fragment = encode_udp_fragment_header(MAX_FLOW_ID, u32::MAX, 0, 2, 2) + .unwrap() + .to_vec(); assert_eq!( - encode_udp_data(0x0102_0304, &[]).unwrap(), - [0x00, 1, 2, 3, 4] + fragment, + [0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0, 2, 0, 2] + ); + fragment.push(42); + let UdpFrame::Fragment { + flow_id, + fragment: decoded, + } = decode_udp_frame(&fragment).unwrap() + else { + panic!("expected fragment"); + }; + assert_eq!(flow_id, MAX_FLOW_ID); + assert_eq!(decoded.packet_id, u32::MAX); + for flow_id in [0, MAX_FLOW_ID + 1, u32::MAX] { + assert!(encode_udp_data(flow_id, &[]).is_err()); + assert!(encode_udp_close(flow_id).is_err()); + assert!(encode_udp_fragment_header(flow_id, 1, 0, 2, 2).is_err()); + } + for len in 0..UDP_HEADER_LEN { + assert!(decode_udp_frame(&data[..len]).is_err()); + } + for len in 0..=UDP_FRAGMENT_HEADER_LEN { + assert!(decode_udp_frame(&fragment[..len]).is_err()); + } +} + +#[test] +fn fragmentation_starts_exactly_above_the_new_data_capacity() { + for max_size in [13, 64, 1200] { + let exact = vec![0x5a; max_size - UDP_HEADER_LEN]; + let frames = encode_udp_data_fragments(1, 1, &exact, max_size).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].len(), max_size); + assert!(matches!( + decode_udp_frame(&frames[0]).unwrap(), + UdpFrame::Data { .. } + )); + + let larger = vec![0x5a; exact.len() + 1]; + let frames = encode_udp_data_fragments(1, 1, &larger, max_size).unwrap(); + assert!(frames.len() >= 2); + let mut recovered = Vec::new(); + for frame in frames { + assert!(frame.len() <= max_size); + let UdpFrame::Fragment { fragment, .. } = decode_udp_frame(&frame).unwrap() else { + panic!("expected fragment"); + }; + recovered.extend_from_slice(fragment.payload); + } + assert_eq!(recovered, larger); + } +} + +#[test] +fn invalid_reassembly_id_cannot_reserve_resources() { + let mut reassembler = DatagramReassembler::<()>::new(ReassemblyConfig::default()); + let fragment = OwnedUdpFragment { + packet_id: 1, + fragment_index: 0, + fragment_count: 2, + total_len: 2, + payload: Bytes::from_static(b"a"), + }; + for id in [0, MAX_FLOW_ID + 1, u32::MAX] { + assert!(matches!( + reassembler.push_with(id, fragment.clone(), Instant::now(), |_| panic!( + "invalid ID reserved resources" + )), + ReassemblyOutcome::Dropped(_) + )); + assert_eq!(reassembler.slot_count(), 0); + assert_eq!(reassembler.reserved_bytes(), 0); + } +} + +#[test] +fn normal_data_and_close_have_exact_four_byte_headers() { + assert_eq!( + encode_udp_data(0x0102_0304, &[0xaa, 0xbb]).unwrap(), + [1, 2, 3, 4, 0xaa, 0xbb] ); - assert_eq!(encode_udp_close(0x0102_0304).unwrap(), [0x02, 1, 2, 3, 4]); - assert_eq!(UDP_HEADER_LEN, 5); + assert_eq!(encode_udp_data(0x0102_0304, &[]).unwrap(), [1, 2, 3, 4]); + assert_eq!(encode_udp_close(0x0102_0304).unwrap(), [0x81, 2, 3, 4]); + assert_eq!(UDP_HEADER_LEN, 4); } #[test] @@ -37,14 +134,14 @@ fn unfragmented_packets_never_carry_fragment_metadata() { } #[test] -fn fragmented_packets_use_exact_thirteen_byte_headers() { +fn fragmented_packets_use_exact_twelve_byte_headers() { let payload = vec![0x5a; 2500]; let frames = encode_udp_data_fragments(0x0102_0304, 0x1122_3344, &payload, 1200).unwrap(); assert_eq!(frames.len(), 3); - assert_eq!(UDP_FRAGMENT_HEADER_LEN, 13); + assert_eq!(UDP_FRAGMENT_HEADER_LEN, 12); assert_eq!( &frames[0][..UDP_FRAGMENT_HEADER_LEN], - &[0x01, 1, 2, 3, 4, 0x11, 0x22, 0x33, 0x44, 0, 3, 0x09, 0xc4] + &[0x41, 2, 3, 4, 0x11, 0x22, 0x33, 0x44, 0, 3, 0x09, 0xc4] ); let mut assembled = Vec::new(); @@ -92,11 +189,11 @@ fn lazy_fragment_plan_materializes_only_requested_frames() { #[test] fn lazy_fragment_plan_enforces_two_to_255_fragments() { - assert!(encode_udp_fragments(1, 1, &[0; 9], 14).is_err()); - let fragments = encode_udp_fragments(1, 1, &[0; 255], 14).unwrap(); + assert!(encode_udp_fragments(1, 1, &[0; 9], 13).is_err()); + let fragments = encode_udp_fragments(1, 1, &[0; 255], 13).unwrap(); assert_eq!(fragments.len(), 255); - assert!(encode_udp_fragments(1, 1, &[0; 256], 14).is_err()); - assert!(encode_udp_fragments(1, 0, &[0; 20], 14).is_err()); + assert!(encode_udp_fragments(1, 1, &[0; 256], 13).is_err()); + assert!(encode_udp_fragments(1, 0, &[0; 20], 13).is_err()); } #[test] @@ -139,35 +236,33 @@ fn owned_decoder_slices_data_and_fragment_payloads_without_copying() { }; assert_eq!(flow_id, 7); assert_eq!(fragment.payload.as_ptr(), fragment_payload_ptr); - assert_eq!(fragment.payload, [0x5a; 51][..]); + assert_eq!(fragment.payload, [0x5a; 52][..]); } #[test] -fn flow_and_packet_ids_must_be_nonzero() { +fn flow_ids_must_fit_thirty_bits_and_packet_ids_must_be_nonzero() { assert!(encode_udp_data(0, b"x").is_err()); assert!(encode_udp_close(0).is_err()); assert!(encode_udp_data_fragments(0, 1, b"x", 64).is_err()); + assert!(encode_udp_data(MAX_FLOW_ID, b"x").is_ok()); + assert!(encode_udp_data(MAX_FLOW_ID + 1, b"x").is_err()); assert!(encode_udp_fragment_header(1, 0, 0, 2, 2).is_err()); let mut fragment = encode_udp_data_fragments(1, 9, &[1; 100], 64) .unwrap() .remove(0); - fragment[5..9].fill(0); + fragment[4..8].fill(0); assert!(decode_udp_frame(&fragment).is_err()); } #[test] -fn decoder_rejects_short_reserved_unknown_and_close_payload_frames() { +fn decoder_rejects_short_unknown_and_close_payload_frames() { for input in [ vec![], vec![0], vec![0, 0, 0, 0], - vec![0, 0, 0, 0, 0], - vec![3, 0, 0, 0, 1], - vec![0x04, 0, 0, 0, 1], - vec![0x40, 0, 0, 0, 1], - vec![0x82, 0, 0, 0, 1], - vec![2, 0, 0, 0, 1, 0], + vec![0xc0, 0, 0, 1], + vec![0x80, 0, 0, 1, 0], ] { assert!(decode_udp_frame(&input).is_err(), "accepted {input:?}"); } @@ -184,11 +279,11 @@ fn fragment_validation_rejects_every_invalid_metadata_shape() { .unwrap() .remove(0); for mutate in [ - |frame: &mut Vec| frame[10] = 1, - |frame: &mut Vec| frame[9] = frame[10], - |frame: &mut Vec| frame[11..13].fill(0), + |frame: &mut Vec| frame[9] = 1, + |frame: &mut Vec| frame[8] = frame[9], + |frame: &mut Vec| frame[10..12].fill(0), + |frame: &mut Vec| frame.truncate(11), |frame: &mut Vec| frame.truncate(12), - |frame: &mut Vec| frame.truncate(13), ] { let mut frame = valid.clone(); mutate(&mut frame); diff --git a/src/tests/protocol/flow.rs b/src/tests/protocol/flow.rs index 4d57e1f..c2bd669 100644 --- a/src/tests/protocol/flow.rs +++ b/src/tests/protocol/flow.rs @@ -61,7 +61,7 @@ fn every_valid_role_kind_and_carrier_combination_round_trips() { for role in [FlowRole::Open, FlowRole::Attach] { round_trip(FlowHeader { role, - flow_id: u32::MAX, + flow_id: MAX_FLOW_ID, kind, uplink: Carrier::TlsTcp, downlink: Carrier::Quic, @@ -80,7 +80,7 @@ fn every_valid_role_kind_and_carrier_combination_round_trips() { } #[test] -fn semantic_validation_rejects_zero_ids_and_duplex_carrier_conflicts() { +fn semantic_validation_rejects_out_of_range_ids_and_duplex_carrier_conflicts() { let duplex = FlowHeader { role: FlowRole::Duplex, flow_id: 1, @@ -110,6 +110,14 @@ fn semantic_validation_rejects_zero_ids_and_duplex_carrier_conflicts() { hops: 0, }; assert!(encode_flow_header(zero).is_err()); + + assert!( + encode_flow_header(FlowHeader { + flow_id: MAX_FLOW_ID + 1, + ..zero + }) + .is_err() + ); } #[test] @@ -141,6 +149,11 @@ fn decoder_rejects_invalid_ids_lengths_and_semantics() { assert!(decode_flow_header(input).is_err()); } assert!(decode_flow_header(&[0, 0, 0, 0, 0]).is_err()); + for id in [MAX_FLOW_ID + 1, u32::MAX] { + let mut bytes = [0; FLOW_HEADER_LEN]; + bytes[1..].copy_from_slice(&id.to_be_bytes()); + assert!(decode_flow_header(&bytes).is_err()); + } let valid = [0, 0, 0, 0, 1]; let mut invalid_role = valid; @@ -193,3 +206,19 @@ fn round_trip(header: FlowHeader) { assert_eq!(encoded.len(), FLOW_HEADER_LEN); assert_eq!(decode_flow_header(&encoded).unwrap(), header); } + +#[test] +fn both_public_encoders_reject_invalid_ids() { + for flow_id in [0, MAX_FLOW_ID + 1, u32::MAX] { + let header = FlowHeader { + role: FlowRole::Duplex, + flow_id, + kind: FlowKind::Tcp, + uplink: Carrier::TlsTcp, + downlink: Carrier::TlsTcp, + hops: 0, + }; + assert!(write_flow_header(header).is_err()); + assert!(encode_flow_header(header).is_err()); + } +} diff --git a/src/tests/telemetry/hub.rs b/src/tests/telemetry/hub.rs index 9318e22..6a49215 100644 --- a/src/tests/telemetry/hub.rs +++ b/src/tests/telemetry/hub.rs @@ -3,26 +3,35 @@ use std::sync::atomic::Ordering; use crate::protocol::Carrier; use crate::telemetry::wire::InstanceDescriptor; use crate::telemetry::{ - AccessOutcome, AccessStart, InstanceRole, PROTOCOL_VERSION, ServerMessage, TelemetryHub, + AccessOutcome, AccessStart, InstanceRole, ServerMessage, TELEMETRY_VERSION, TelemetryHub, TrafficProtocol, }; use crate::transport::Stats; fn descriptor() -> InstanceDescriptor { InstanceDescriptor { - protocol_version: PROTOCOL_VERSION, + telemetry_version: TELEMETRY_VERSION, id: "1:2:3".to_owned(), role: InstanceRole::Portal, pid: 2, uid: 1, incarnation: 3, version: "test".to_owned(), - endpoint: ":2077".to_owned(), + endpoint: ":2000".to_owned(), config_summary: "portal net=mix".to_owned(), telemetry_interval_ms: 1_000, } } +#[test] +fn listener_summary_uses_bound_addresses_without_inventing_a_second_family() { + let hub = TelemetryHub::new(descriptor()); + hub.set_listening_addresses("0.0.0.0:2000", "none"); + let summary = &hub.descriptor().config_summary; + assert!(summary.ends_with("tcp=0.0.0.0:2000 udp=none")); + assert!(!summary.contains("[::]")); +} + #[test] fn access_span_finishes_only_once() { let hub = TelemetryHub::new(descriptor()); @@ -31,7 +40,6 @@ fn access_span_finishes_only_once() { id: 0, timestamp_ms: 1, protocol: TrafficProtocol::Tcp, - alpn: "now/1".to_owned(), flow_id: Some(7), session_tag: Some("abc123".to_owned()), client: Some("127.0.0.1:1".to_owned()), diff --git a/src/tests/telemetry/ipc.rs b/src/tests/telemetry/ipc.rs index 6533df9..1274e01 100644 --- a/src/tests/telemetry/ipc.rs +++ b/src/tests/telemetry/ipc.rs @@ -10,7 +10,7 @@ use crate::transport::Stats; fn parse_registry_name(name: &str) -> Option { let mut components = name.split('.'); - if components.next()? != "nowhere" || components.next()? != "v2" { + if components.next()? != "nowhere" || components.next()? != "2" { return None; } let uid = components.next()?.parse().ok()?; @@ -28,23 +28,23 @@ fn parse_registry_name(name: &str) -> Option { } #[test] -fn parses_only_exact_v2_registry_names() { +fn parses_only_nowhere_2_registry_names() { assert_eq!( - parse_registry_name("nowhere.v2.1000.42.900"), + parse_registry_name("nowhere.2.1000.42.900"), Some(DiscoveredInstance { - registry_name: "nowhere.v2.1000.42.900".to_owned(), + registry_name: "nowhere.2.1000.42.900".to_owned(), uid: 1000, pid: 42, incarnation: 900, }) ); - assert!(parse_registry_name("nowhere.v1.1000.42.900").is_none()); - assert!(parse_registry_name("nowhere.v2.1000.42").is_none()); - assert!(parse_registry_name("nowhere.v2.1000.42.900.extra").is_none()); + assert!(parse_registry_name("nowhere.v2.1000.42.900").is_none()); + assert!(parse_registry_name("nowhere.2.1000.42").is_none()); + assert!(parse_registry_name("nowhere.2.1000.42.900.extra").is_none()); } #[test] -fn v2_snapshot_round_trips_transport_counters() { +fn nowhere_2_snapshot_round_trips_transport_counters() { let snapshot = TelemetrySnapshot { tls_carriers_active: 2, quic_carriers_active: 3, @@ -115,7 +115,7 @@ async fn slow_frame_writes_time_out() { async fn multiple_clients_can_read_and_change_subscriptions() { let mut descriptor = InstanceDescriptor::current( InstanceRole::Portal, - ":2077", + ":2000", "net=mix", Duration::from_secs(1), ) diff --git a/src/tests/transport/morph.rs b/src/tests/transport/morph.rs new file mode 100644 index 0000000..5e6fb6b --- /dev/null +++ b/src/tests/transport/morph.rs @@ -0,0 +1,9 @@ +// Copyright (C) 2026 NodePassProject +// SPDX-License-Identifier: GPL-3.0-only + +#[path = "morph/keys.rs"] +mod keys; +#[path = "morph/tcp.rs"] +mod tcp; +#[path = "morph/udp.rs"] +mod udp; diff --git a/src/tests/transport/morph/keys.rs b/src/tests/transport/morph/keys.rs new file mode 100644 index 0000000..0c66969 --- /dev/null +++ b/src/tests/transport/morph/keys.rs @@ -0,0 +1,30 @@ +// Copyright (C) 2026 NodePassProject +// SPDX-License-Identifier: GPL-3.0-only + +use super::super::*; + +fn hex(value: &str) -> [u8; N] { + assert_eq!(value.len(), N * 2); + let mut bytes = [0; N]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).unwrap(); + } + bytes +} + +#[test] +fn derives_fixed_hkdf_sha256_keys() { + let keys = MorphKeys::derive(b"test portal key"); + assert_eq!( + keys.tcp_c2s, + hex("90df47db82553ab6b0489ea77a085593475a70c6a61e957ad3ffe0824bd2126a") + ); + assert_eq!( + keys.tcp_s2c, + hex("20bc17a22d08469e60efd4c6bdda76f190c33946599a0797bab2d52007b97e27") + ); + assert_eq!( + keys.udp, + hex("484b5f06a66e566099da4886e3bb2ec342aedee0ebcd7ea426d68ece4f3f8ea2") + ); +} diff --git a/src/tests/transport/morph/tcp.rs b/src/tests/transport/morph/tcp.rs new file mode 100644 index 0000000..6c8a30e --- /dev/null +++ b/src/tests/transport/morph/tcp.rs @@ -0,0 +1,316 @@ +// Copyright (C) 2026 NodePassProject +// SPDX-License-Identifier: GPL-3.0-only + +use std::collections::VecDeque; +use std::io; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::task::{Context, Poll, Waker}; + +use chacha20::ChaCha20; +use chacha20::cipher::KeyIvInit; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; + +use super::super::*; + +impl super::super::tcp::MorphWriteReady for tokio::io::DuplexStream { + fn poll_morph_write_ready(&self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +fn hex(value: &str) -> [u8; N] { + assert_eq!(value.len(), N * 2); + let mut bytes = [0; N]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).unwrap(); + } + bytes +} + +fn set_tcp_nonce(stream: &mut MorphTcpStream, nonce: [u8; NONCE_LEN]) { + let state = stream.morph.as_mut().unwrap(); + state.prefix = nonce; + state.read_cipher = Some(ChaCha20::new( + (&state.read_key).into(), + (&state.prefix).into(), + )); + state.write_cipher = Some(ChaCha20::new( + (&state.write_key).into(), + (&state.prefix).into(), + )); +} + +#[test] +fn chacha20_starts_at_block_zero() { + let mut block = [0u8; 64]; + apply_at(&[0; 32], &[0; 12], 0, &mut block).unwrap(); + assert_eq!( + block, + hex( + "76b8e0ada0f13d90405d6ae55386bd28bdd219b8a08ded1aa836efcc8b770dc7da41597c5157488d7724e03fb8d84a376a43b8f41518a11cc387b669b2ee6586" + ) + ); +} + +#[test] +fn tcp_empty_io_does_not_wait_for_the_nonce() { + let keys = MorphKeys::derive(b"shared"); + let (_client_io, server_io) = tokio::io::duplex(64); + let mut server = MorphTcpStream::server(server_io, Some(keys)); + let waker = Waker::noop(); + let mut context = Context::from_waker(waker); + let mut empty = []; + let mut read_buf = ReadBuf::new(&mut empty); + + assert!(matches!( + Pin::new(&mut server).poll_read(&mut context, &mut read_buf), + Poll::Ready(Ok(())) + )); + assert!(matches!( + Pin::new(&mut server).poll_write(&mut context, &[]), + Poll::Ready(Ok(0)) + )); +} + +#[derive(Debug)] +struct PendingWriteStream { + ready: Arc, + writes: Arc, + written: Arc>>, + outcomes: std::sync::Mutex>, +} + +#[derive(Debug)] +enum WriteOutcome { + Accept(usize), + Pending, + Error(io::ErrorKind), +} + +impl AsyncRead for PendingWriteStream { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut ReadBuf<'_>, + ) -> Poll> { + Poll::Pending + } +} + +impl AsyncWrite for PendingWriteStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + self.writes.fetch_add(1, Ordering::Relaxed); + let outcome = self.outcomes.lock().unwrap().pop_front(); + match outcome.unwrap_or(WriteOutcome::Accept(input.len())) { + WriteOutcome::Accept(limit) => { + let count = input.len().min(limit); + self.written + .lock() + .unwrap() + .extend_from_slice(&input[..count]); + Poll::Ready(Ok(count)) + } + WriteOutcome::Pending => { + cx.waker().wake_by_ref(); + Poll::Pending + } + WriteOutcome::Error(kind) => Poll::Ready(Err(kind.into())), + } + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +impl super::super::tcp::MorphWriteReady for PendingWriteStream { + fn poll_morph_write_ready(&self, _cx: &mut Context<'_>) -> Poll> { + if self.ready.load(Ordering::Relaxed) { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } +} + +#[test] +fn tcp_waits_for_write_readiness_before_copying_or_xoring() { + let ready = Arc::new(AtomicBool::new(false)); + let writes = Arc::new(AtomicUsize::new(0)); + let written = Arc::new(std::sync::Mutex::new(Vec::new())); + let inner = PendingWriteStream { + ready: ready.clone(), + writes: writes.clone(), + written: written.clone(), + outcomes: Default::default(), + }; + let keys = MorphKeys::derive(b"shared"); + let mut client = MorphTcpStream::client(inner, Some(keys)).unwrap(); + set_tcp_nonce(&mut client, [11; NONCE_LEN]); + let waker = Waker::noop(); + let mut context = Context::from_waker(waker); + + assert!(matches!( + Pin::new(&mut client).poll_write(&mut context, b"initial"), + Poll::Pending + )); + // Only the nonce prefix reaches the underlying stream. + assert_eq!(writes.load(Ordering::Relaxed), 1); + assert!(matches!( + Pin::new(&mut client).poll_write(&mut context, b"payload"), + Poll::Pending + )); + assert_eq!(writes.load(Ordering::Relaxed), 1); + assert!(client.morph.as_ref().unwrap().write_buffer.is_empty()); + + ready.store(true, Ordering::Relaxed); + assert!(matches!( + Pin::new(&mut client).poll_write(&mut context, b"changed"), + Poll::Ready(Ok(7)) + )); + assert_eq!(writes.load(Ordering::Relaxed), 2); + let mut payload = written.lock().unwrap()[NONCE_LEN..].to_vec(); + apply_at( + &client.morph.as_ref().unwrap().write_key, + &[11; NONCE_LEN], + 0, + &mut payload, + ) + .unwrap(); + assert_eq!(&payload, b"changed"); +} + +#[test] +fn tcp_retries_changed_input_after_short_write_pending_and_error() { + let written = Arc::new(std::sync::Mutex::new(Vec::new())); + let inner = PendingWriteStream { + ready: Arc::new(AtomicBool::new(true)), + writes: Arc::new(AtomicUsize::new(0)), + written: written.clone(), + outcomes: std::sync::Mutex::new(VecDeque::from([ + WriteOutcome::Accept(NONCE_LEN), + WriteOutcome::Accept(3), + WriteOutcome::Pending, + WriteOutcome::Error(io::ErrorKind::Interrupted), + WriteOutcome::Accept(usize::MAX), + ])), + }; + let mut client = MorphTcpStream::client(inner, Some(MorphKeys::derive(b"shared"))).unwrap(); + set_tcp_nonce(&mut client, [12; NONCE_LEN]); + let mut context = Context::from_waker(Waker::noop()); + + assert!(matches!( + Pin::new(&mut client).poll_write(&mut context, b"abc-unaccepted"), + Poll::Ready(Ok(3)) + )); + assert!(matches!( + Pin::new(&mut client).poll_write(&mut context, b"pending input"), + Poll::Pending + )); + assert!(matches!( + Pin::new(&mut client).poll_write(&mut context, b"error input"), + Poll::Ready(Err(error)) if error.kind() == io::ErrorKind::Interrupted + )); + assert!(matches!( + Pin::new(&mut client).poll_write(&mut context, b"z"), + Poll::Ready(Ok(1)) + )); + let wire = written.lock().unwrap(); + assert_eq!(&wire[..NONCE_LEN], &[12; NONCE_LEN]); + let mut payload = wire[NONCE_LEN..].to_vec(); + apply_at( + &client.morph.as_ref().unwrap().write_key, + &[12; NONCE_LEN], + 0, + &mut payload, + ) + .unwrap(); + assert_eq!(&payload, b"abcz"); +} + +#[tokio::test] +async fn tcp_uses_one_client_nonce_and_independent_directions() { + let keys = MorphKeys::derive(b"shared"); + let (client_io, server_io) = tokio::io::duplex(4096); + let mut client = MorphTcpStream::client(client_io, Some(keys.clone())).unwrap(); + set_tcp_nonce(&mut client, [7; NONCE_LEN]); + let mut server = MorphTcpStream::server(server_io, Some(keys)); + + client.write_all(b"client hello").await.unwrap(); + let mut request = [0; 12]; + server.read_exact(&mut request).await.unwrap(); + assert_eq!(&request, b"client hello"); + + server.write_all(b"server reply").await.unwrap(); + let mut response = [0; 12]; + client.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"server reply"); +} + +#[tokio::test] +async fn tcp_server_waits_for_the_client_nonce_before_writing() { + let keys = MorphKeys::derive(b"shared"); + let (client_io, server_io) = tokio::io::duplex(4096); + let mut client = MorphTcpStream::client(client_io, Some(keys.clone())).unwrap(); + set_tcp_nonce(&mut client, [8; NONCE_LEN]); + let server = MorphTcpStream::server(server_io, Some(keys)); + let (mut server_reader, mut server_writer) = tokio::io::split(server); + + let reply = tokio::spawn(async move { server_writer.write_all(b"reply").await }); + tokio::task::yield_now().await; + assert!(!reply.is_finished()); + + client.write_all(b"hello").await.unwrap(); + let mut request = [0; 5]; + server_reader.read_exact(&mut request).await.unwrap(); + assert_eq!(&request, b"hello"); + reply.await.unwrap().unwrap(); + + let mut response = [0; 5]; + client.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"reply"); +} + +#[tokio::test] +async fn tcp_preserves_offsets_across_small_io_chunks() { + let keys = MorphKeys::derive(b"shared"); + let (client_io, server_io) = tokio::io::duplex(1024); + let mut client = MorphTcpStream::client(client_io, Some(keys.clone())).unwrap(); + set_tcp_nonce(&mut client, [9; NONCE_LEN]); + let mut server = MorphTcpStream::server(server_io, Some(keys)); + let payload = vec![0x5a; 65_537]; + let expected = payload.clone(); + + let writer = tokio::spawn(async move { client.write_all(&payload).await }); + let mut received = vec![0; expected.len()]; + server.read_exact(&mut received).await.unwrap(); + writer.await.unwrap().unwrap(); + assert_eq!(received, expected); +} + +#[tokio::test] +async fn tcp_write_processes_the_full_available_buffer() { + let keys = MorphKeys::derive(b"shared"); + let (client_io, _peer_io) = tokio::io::duplex(256 * 1024); + let mut client = MorphTcpStream::client(client_io, Some(keys)).unwrap(); + set_tcp_nonce(&mut client, [10; NONCE_LEN]); + let payload = vec![0x5a; 128 * 1024]; + + assert_eq!(client.write(&payload).await.unwrap(), payload.len()); + let buffer_len = client.morph.as_ref().unwrap().write_buffer.len(); + assert_eq!(client.write(b"x").await.unwrap(), 1); + assert_eq!( + client.morph.as_ref().unwrap().write_buffer.len(), + buffer_len + ); +} diff --git a/src/tests/transport/morph/udp.rs b/src/tests/transport/morph/udp.rs new file mode 100644 index 0000000..6d8cc39 --- /dev/null +++ b/src/tests/transport/morph/udp.rs @@ -0,0 +1,359 @@ +// Copyright (C) 2026 NodePassProject +// SPDX-License-Identifier: GPL-3.0-only + +use std::collections::VecDeque; +use std::future::poll_fn; +use std::io::{self, IoSliceMut}; +use std::pin::Pin; +use std::sync::{Arc, Mutex, Mutex as StdMutex}; +use std::task::{Context, Poll}; + +use quinn::udp::{RecvMeta, Transmit}; +use quinn::{AsyncUdpSocket, UdpPoller}; + +use super::super::udp::{UDP_NONCE_STREAM_LIMIT, UdpNonceGenerator}; +use super::super::*; + +#[test] +fn udp_nonce_generator_fills_batches_from_one_stream() { + let mut buffers = UdpBuffers::from_seed([3; 32]); + let mut first = [0; NONCE_LEN]; + let mut second = [0; NONCE_LEN]; + buffers.nonce_generator.generate(&mut first).unwrap(); + buffers.nonce_generator.generate(&mut second).unwrap(); + + assert_ne!(first, [0; NONCE_LEN]); + assert_ne!(second, first); +} + +#[test] +fn udp_nonce_generator_reseeds_before_exhaustion_and_retries_failures() { + let mut generator = UdpNonceGenerator::from_seed([3; 32]); + generator.generated = UDP_NONCE_STREAM_LIMIT; + let mut nonce = [9; NONCE_LEN]; + + let error = generator + .generate_with_reseed(&mut nonce, || Err(io::Error::other("no entropy"))) + .unwrap_err(); + assert_eq!(error.to_string(), "no entropy"); + assert_eq!(generator.generated, UDP_NONCE_STREAM_LIMIT); + assert_eq!(nonce, [9; NONCE_LEN]); + + generator + .generate_with_reseed(&mut nonce, || Ok([4; 32])) + .unwrap(); + assert_eq!(generator.generated, NONCE_LEN as u64); + let mut expected = [0; NONCE_LEN]; + UdpNonceGenerator::from_seed([4; 32]) + .generate_with_reseed(&mut expected, || panic!("fresh stream must not reseed")) + .unwrap(); + assert_eq!(nonce, expected); +} + +#[test] +fn morph_endpoint_reserves_the_udp_nonce_overhead() { + let plain = morph_endpoint_config(false).unwrap(); + let morph = morph_endpoint_config(true).unwrap(); + + assert_eq!( + morph.get_max_udp_payload_size() + NONCE_LEN as u64, + plain.get_max_udp_payload_size() + ); +} + +#[derive(Debug, Default)] +struct FakeUdpSocket { + sent: StdMutex, Option)>>, + receive: StdMutex, RecvMeta)>>, + receive_lengths: StdMutex>, +} + +#[derive(Debug)] +struct ReadyPoller; + +impl std::future::Future for ReadyPoller { + type Output = io::Result<()>; + + fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll { + Poll::Ready(Ok(())) + } +} + +impl UdpPoller for ReadyPoller { + fn poll_writable(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +impl AsyncUdpSocket for FakeUdpSocket { + fn create_io_poller(self: Arc) -> Pin> { + Box::pin(ReadyPoller) + } + + fn try_send(&self, transmit: &Transmit<'_>) -> io::Result<()> { + self.sent + .lock() + .unwrap() + .push((transmit.contents.to_vec(), transmit.segment_size)); + Ok(()) + } + + fn poll_recv( + &self, + _cx: &mut Context<'_>, + bufs: &mut [IoSliceMut<'_>], + meta: &mut [RecvMeta], + ) -> Poll> { + let Some((packet, packet_meta)) = self.receive.lock().unwrap().pop_front() else { + return Poll::Pending; + }; + self.receive_lengths.lock().unwrap().push(bufs[0].len()); + bufs[0][..packet.len()].copy_from_slice(&packet); + meta[0] = packet_meta; + Poll::Ready(Ok(1)) + } + + fn local_addr(&self) -> io::Result { + Ok("127.0.0.1:1".parse().unwrap()) + } + + fn max_transmit_segments(&self) -> usize { + 2 + } + + fn max_receive_segments(&self) -> usize { + 2 + } +} + +#[tokio::test] +async fn udp_preserves_gso_datagram_boundaries() { + let key = MorphKeys::derive(b"shared").udp_key(); + let raw = Arc::new(FakeUdpSocket::default()); + let socket = MorphUdpSocket { + inner: raw.clone(), + key, + buffers: Mutex::new(UdpBuffers::from_seed([1; 32])), + }; + for plain in [ + b"abcdef".as_slice(), + b"abcde".as_slice(), + b"ghijkl".as_slice(), + ] { + socket + .try_send(&Transmit { + destination: "127.0.0.1:2".parse().unwrap(), + ecn: None, + contents: plain, + segment_size: Some(3), + src_ip: None, + }) + .unwrap(); + let (wire, stride) = raw.sent.lock().unwrap().pop().unwrap(); + assert_eq!(stride, Some(15)); + assert_eq!(wire.len(), plain.len() + 2 * NONCE_LEN); + assert_ne!(&wire[..NONCE_LEN], &wire[15..15 + NONCE_LEN]); + + let wire_len = wire.len(); + raw.receive.lock().unwrap().push_back(( + wire, + RecvMeta { + addr: "127.0.0.1:2".parse().unwrap(), + len: wire_len, + stride: 15, + ecn: None, + dst_ip: None, + }, + )); + let mut output = [0xa7; 6]; + let mut bufs = [IoSliceMut::new(&mut output)]; + let mut meta = [RecvMeta::default()]; + let count = poll_fn(|cx| socket.poll_recv(cx, &mut bufs, &mut meta)) + .await + .unwrap(); + assert_eq!(count, 1); + assert_eq!(&output[..plain.len()], plain); + assert!(output[plain.len()..].iter().all(|byte| *byte == 0xa7)); + assert_eq!(meta[0].len, plain.len()); + assert_eq!(meta[0].stride, 3); + } +} + +#[tokio::test] +async fn udp_discards_invalid_wire_datagrams_before_returning_valid_data() { + let key = MorphKeys::derive(b"shared").udp_key(); + let raw = Arc::new(FakeUdpSocket::default()); + let socket = MorphUdpSocket { + inner: raw.clone(), + key, + buffers: Mutex::new(UdpBuffers::from_seed([2; 32])), + }; + let packet_meta = |len| RecvMeta { + addr: "127.0.0.1:2".parse().unwrap(), + len, + stride: len, + ecn: None, + dst_ip: None, + }; + raw.receive + .lock() + .unwrap() + .push_back((vec![0; NONCE_LEN], packet_meta(NONCE_LEN))); + + socket + .try_send(&Transmit { + destination: "127.0.0.1:2".parse().unwrap(), + ecn: None, + contents: b"oversized", + segment_size: None, + src_ip: None, + }) + .unwrap(); + let (oversized, _) = raw.sent.lock().unwrap().pop().unwrap(); + let oversized_len = oversized.len(); + raw.receive + .lock() + .unwrap() + .push_back((oversized, packet_meta(oversized_len))); + + raw.receive + .lock() + .unwrap() + .push_back((vec![0; NONCE_LEN], packet_meta(5 + NONCE_LEN * 2 + 1))); + + socket + .try_send(&Transmit { + destination: "127.0.0.1:2".parse().unwrap(), + ecn: None, + contents: b"valid", + segment_size: None, + src_ip: None, + }) + .unwrap(); + let (wire, _) = raw.sent.lock().unwrap().pop().unwrap(); + let wire_len = wire.len(); + raw.receive + .lock() + .unwrap() + .push_back((wire, packet_meta(wire_len))); + + let mut output = [0u8; 5]; + let mut bufs = [IoSliceMut::new(&mut output)]; + let mut meta = [RecvMeta::default()]; + let count = poll_fn(|cx| socket.poll_recv(cx, &mut bufs, &mut meta)) + .await + .unwrap(); + assert_eq!(count, 1); + assert_eq!(&output, b"valid"); + assert_eq!(meta[0].len, 5); +} + +#[tokio::test] +async fn udp_reuses_buffers_without_relaxing_current_receive_bounds() { + let key = MorphKeys::derive(b"shared").udp_key(); + let raw = Arc::new(FakeUdpSocket::default()); + let socket = MorphUdpSocket { + inner: raw.clone(), + key, + buffers: Mutex::new(UdpBuffers::from_seed([5; 32])), + }; + let destination = "127.0.0.1:2".parse().unwrap(); + let packet_meta = |len| RecvMeta { + addr: destination, + len, + stride: len, + ecn: None, + dst_ip: None, + }; + + socket + .try_send(&Transmit { + destination, + ecn: None, + contents: &[0x5a; 32], + segment_size: None, + src_ip: None, + }) + .unwrap(); + let (large_wire, _) = raw.sent.lock().unwrap().pop().unwrap(); + let large_wire_len = large_wire.len(); + raw.receive + .lock() + .unwrap() + .push_back((large_wire, packet_meta(large_wire_len))); + let mut large_output = [0; 32]; + let mut large_bufs = [IoSliceMut::new(&mut large_output)]; + let mut large_meta = [RecvMeta::default()]; + poll_fn(|cx| socket.poll_recv(cx, &mut large_bufs, &mut large_meta)) + .await + .unwrap(); + assert_eq!(large_output, [0x5a; 32]); + + socket + .try_send(&Transmit { + destination, + ecn: None, + contents: b"x", + segment_size: None, + src_ip: None, + }) + .unwrap(); + let (small_wire, _) = raw.sent.lock().unwrap().pop().unwrap(); + assert_eq!(small_wire.len(), NONCE_LEN + 1); + // Three one-byte GRO payloads would fit the decoded target, but the + // claimed wire length exceeds this call's receive slice (4 + 2 * 12). + raw.receive.lock().unwrap().push_back(( + vec![0; NONCE_LEN], + RecvMeta { + len: 3 * (NONCE_LEN + 1), + stride: NONCE_LEN + 1, + ..packet_meta(0) + }, + )); + let small_wire_len = small_wire.len(); + raw.receive + .lock() + .unwrap() + .push_back((small_wire, packet_meta(small_wire_len))); + let mut small_output = [0xa7; 4]; + let mut small_bufs = [IoSliceMut::new(&mut small_output)]; + let mut small_meta = [RecvMeta::default()]; + poll_fn(|cx| socket.poll_recv(cx, &mut small_bufs, &mut small_meta)) + .await + .unwrap(); + assert_eq!(small_meta[0].len, 1); + assert_eq!(small_output, [b'x', 0xa7, 0xa7, 0xa7]); + + socket + .try_send(&Transmit { + destination, + ecn: None, + contents: &[0xa5; 48], + segment_size: None, + src_ip: None, + }) + .unwrap(); + let (larger_wire, _) = raw.sent.lock().unwrap().pop().unwrap(); + assert_eq!(larger_wire.len(), NONCE_LEN + 48); + let larger_wire_len = larger_wire.len(); + raw.receive + .lock() + .unwrap() + .push_back((larger_wire, packet_meta(larger_wire_len))); + let mut larger_output = [0; 48]; + let mut larger_bufs = [IoSliceMut::new(&mut larger_output)]; + let mut larger_meta = [RecvMeta::default()]; + poll_fn(|cx| socket.poll_recv(cx, &mut larger_bufs, &mut larger_meta)) + .await + .unwrap(); + assert_eq!(larger_output, [0xa5; 48]); + assert_eq!( + *raw.receive_lengths.lock().unwrap(), + [ + 32 + 2 * NONCE_LEN, + 4 + 2 * NONCE_LEN, + 4 + 2 * NONCE_LEN, + 48 + 2 * NONCE_LEN + ] + ); +} diff --git a/src/tests/transport/owned_io.rs b/src/tests/transport/owned_io.rs new file mode 100644 index 0000000..283afaa --- /dev/null +++ b/src/tests/transport/owned_io.rs @@ -0,0 +1,42 @@ +use super::*; +use crate::mux::{MuxConfig, MuxHandle}; + +#[tokio::test] +async fn owned_bytes_enter_mux_without_a_borrowed_payload_copy() { + let (left, right) = tokio::io::duplex(1 << 20); + let (client, _) = MuxHandle::start(left, MuxConfig::default()).unwrap(); + let (_server, mut incoming) = MuxHandle::start(right, MuxConfig::default()).unwrap(); + let stream = client.open_stream(901).await.unwrap(); + let (_reader, writer) = stream.into_split(); + let mut writer: Pin> = Box::pin(writer); + let mut peer = incoming.accept().await.unwrap().unwrap(); + client.reset_borrowed_write_copies(); + + write_owned( + &mut writer, + RelayChunk::Bytes(Bytes::from_static(b"owned payload")), + ) + .await + .unwrap(); + let mut received = [0; 13]; + peer.read_exact(&mut received).await.unwrap(); + + assert_eq!(&received, b"owned payload"); + assert_eq!(client.borrowed_write_copies(), 0); +} + +#[tokio::test] +async fn generic_reads_use_a_pool_owned_chunk() { + let buffers = Buffers::new(FRAME_BYTES, 1); + let mut reader = &b"pooled"[..]; + let chunk = read_owned_from(&mut reader, &buffers) + .await + .unwrap() + .unwrap(); + + assert_eq!(chunk.as_ref(), b"pooled"); + let allocation = chunk.as_ref().as_ptr(); + drop(chunk); + let reused = buffers.get_tcp_buffer(); + assert_eq!(reused.as_ptr(), allocation); +} diff --git a/src/tests/transport/quic.rs b/src/tests/transport/quic.rs index 56e81ab..28607a2 100644 --- a/src/tests/transport/quic.rs +++ b/src/tests/transport/quic.rs @@ -6,9 +6,9 @@ use super::*; #[test] fn profiles_keep_streams_within_connection_limits() { for profile in [ - parse_quic_profile(Some("memory")).unwrap(), - parse_quic_profile(Some("balanced")).unwrap(), - parse_quic_profile(Some("throughput")).unwrap(), + parse_transport_profile(Some("memory")).unwrap(), + parse_transport_profile(Some("balanced")).unwrap(), + parse_transport_profile(Some("throughput")).unwrap(), ] { assert!(profile.stream_receive_window <= profile.connection_receive_window); assert!(u64::from(profile.connection_receive_window) <= profile.send_window * 2); @@ -18,12 +18,12 @@ fn profiles_keep_streams_within_connection_limits() { #[test] fn throughput_is_the_default_profile() { assert_eq!( - parse_quic_profile(None).unwrap(), - QuicFlowControl::THROUGHPUT + parse_transport_profile(None).unwrap(), + TransportFlowControl::THROUGHPUT ); } #[test] fn rejects_unknown_profiles() { - assert!(parse_quic_profile(Some("tiny")).is_err()); + assert!(parse_transport_profile(Some("tiny")).is_err()); } diff --git a/src/tests/tui/client.rs b/src/tests/tui/client.rs index 736838a..0a61e5a 100644 --- a/src/tests/tui/client.rs +++ b/src/tests/tui/client.rs @@ -1,18 +1,18 @@ use super::*; use crate::telemetry::wire::{InstanceDescriptor, LifecycleSnapshot}; -use crate::telemetry::{AccessOutcome, InstanceRole as WireRole, PROTOCOL_VERSION}; +use crate::telemetry::{AccessOutcome, InstanceRole as WireRole, TELEMETRY_VERSION}; fn hello() -> Hello { Hello { instance: InstanceDescriptor { - protocol_version: PROTOCOL_VERSION, + telemetry_version: TELEMETRY_VERSION, id: "0:42:7".to_owned(), role: WireRole::Portal, pid: 42, uid: 0, incarnation: 7, version: "test".to_owned(), - endpoint: ":2077".to_owned(), + endpoint: ":2000".to_owned(), config_summary: "net=mix".to_owned(), telemetry_interval_ms: 1_000, }, @@ -40,7 +40,6 @@ fn completion_inherits_access_path() { id: 9, timestamp_ms: 1, protocol: TrafficProtocol::Tcp, - alpn: "now/1".to_owned(), flow_id: None, session_tag: Some("abc123".to_owned()), client: Some("10.0.0.1:9".to_owned()), @@ -56,7 +55,6 @@ fn completion_inherits_access_path() { timestamp_ms: 2, duration_ms: 1, protocol: TrafficProtocol::Tcp, - alpn: "now/1".to_owned(), flow_id: None, session_tag: Some("abc123".to_owned()), client: Some("10.0.0.1:9".to_owned()), diff --git a/src/tests/tui/render.rs b/src/tests/tui/render.rs index 1381c6f..f720e52 100644 --- a/src/tests/tui/render.rs +++ b/src/tests/tui/render.rs @@ -22,10 +22,10 @@ fn app_with_instance() -> App { pid: 42, uid: 0, version: "test".to_owned(), - endpoint: "0.0.0.0:2077".to_owned(), + endpoint: "0.0.0.0:2000".to_owned(), config_summary: "net=mix tls=1".to_owned(), telemetry_interval_ms: 1_000, - telemetry_protocol_version: 2, + telemetry_version: 2, }, lifecycle: Lifecycle::Ready, snapshot: Some(TelemetrySnapshot { @@ -108,7 +108,7 @@ fn renders_full_dashboard() { assert!(!output.contains("ACTIVE")); assert!(output.contains("CPU")); assert!(output.contains("RSS")); - assert!(output.contains("LST 0.0.0.0:2077")); + assert!(output.contains("LST 0.0.0.0:2000")); assert!(output.contains("? help")); assert!(!output.contains("telemetry 1000ms")); } @@ -210,7 +210,7 @@ fn renders_narrow_two_page_dashboard() { let output = rendered(72, 20, &app_with_instance()); assert!(output.contains("Overview")); assert!(output.contains("SELECTED")); - assert!(output.contains("0.0.0.0:2077")); + assert!(output.contains("0.0.0.0:2000")); assert!(output.contains("1000ms")); assert!(output.contains("? help")); assert!(!output.contains("telemetry 1000ms")); @@ -277,9 +277,9 @@ fn full_sidebar_keeps_long_lifecycle_labels_visible() { uid: 502, version: "test".to_owned(), endpoint: "[::1]:1082".to_owned(), - config_summary: "portal=relay.example:2077".to_owned(), + config_summary: "portal=relay.example:2000".to_owned(), telemetry_interval_ms: 1_000, - telemetry_protocol_version: 2, + telemetry_version: 2, }, lifecycle: Lifecycle::Starting, snapshot: None, @@ -294,7 +294,7 @@ fn full_sidebar_keeps_long_lifecycle_labels_visible() { fn selected_uses_available_height_for_complete_config() { let mut app = app_with_instance(); app.instances[0].meta.config_summary = - "net=mix tls=1 alpn=now/1 rate=0 etar=0 dial=auto socks=none next=origin.example:3077 up=udp down=tcp mux=0 sni=origin.example pin=present" + "net=mix tls=1 rate=0 etar=0 dial=auto socks=none next=origin.example:3077 up=udp down=tcp mux=0 sni=origin.example pin=present" .to_owned(); let output = rendered(160, 40, &app); diff --git a/src/tests/vector.rs b/src/tests/vector.rs index e1ba5b0..3273e49 100644 --- a/src/tests/vector.rs +++ b/src/tests/vector.rs @@ -19,7 +19,7 @@ fn vector_constructs_for_each_carrier_pair() { ("mix", "mix"), ] { let url = Url::parse(&format!( - "vector://secret@127.0.0.1:2077?up={up}&down={down}&socks=127.0.0.1:1080" + "vector://secret@127.0.0.1:2000?up={up}&down={down}&socks=127.0.0.1:1080" )) .unwrap(); Vector::new(url, Logger::new(crate::common::LogLevel::None, false)).unwrap(); @@ -29,7 +29,7 @@ fn vector_constructs_for_each_carrier_pair() { #[test] fn effective_url_prints_none_for_absent_sni() { let config = VectorConfig::from_url( - &Url::parse("vector://secret@127.0.0.1:2077?socks=127.0.0.1:1080").unwrap(), + &Url::parse("vector://secret@127.0.0.1:2000?socks=127.0.0.1:1080").unwrap(), ) .unwrap(); assert!(config.effective_url().contains("&sni=none&")); @@ -41,7 +41,7 @@ async fn socks_bind_failure_moves_lifecycle_to_stopped() { let port = blocker.local_addr().unwrap().port(); let vector = Vector::new( Url::parse(&format!( - "vector://secret@127.0.0.1:2077?socks=127.0.0.1:{port}&log=none" + "vector://secret@127.0.0.1:2000?socks=127.0.0.1:{port}&log=none" )) .unwrap(), Logger::new(crate::common::LogLevel::None, false), diff --git a/src/tests/vector/config.rs b/src/tests/vector/config.rs index 5f18ae8..791601d 100644 --- a/src/tests/vector/config.rs +++ b/src/tests/vector/config.rs @@ -5,11 +5,10 @@ fn parse(raw: &str) -> Result { } #[test] -fn defaults_to_quic_both_directions() { - let config = parse("vector://secret@example.com:2077?socks=:1080").unwrap(); - assert_eq!(config.up, CarrierMode::Udp); - assert_eq!(config.down, CarrierMode::Udp); - assert_eq!(config.alpn, "now/1"); +fn dual_carrier_endpoint_defaults_to_tcp_without_mux() { + let config = parse("vector://secret@example.com:2000?socks=:1080").unwrap(); + assert_eq!(config.up, CarrierMode::Tcp); + assert_eq!(config.down, CarrierMode::Tcp); assert_eq!(config.mux, MuxMode::Disabled); assert_eq!(config.sni, None); assert_eq!(config.pin, None); @@ -17,10 +16,69 @@ fn defaults_to_quic_both_directions() { assert_eq!(config.socks.port, 1080); } +#[test] +fn explicit_endpoints_select_ports_families_and_single_carrier_defaults() { + let tcp = parse("vector://secret@example.com/tcp4:2006?socks=:1080").unwrap(); + assert_eq!(tcp.up, CarrierMode::Tcp); + assert_eq!(tcp.down, CarrierMode::Tcp); + assert_eq!(tcp.portal_endpoint(), "example.com/tcp4:2006"); + + let udp = parse("vector://secret@example.com/udp6:2017?socks=:1080").unwrap(); + assert_eq!(udp.up, CarrierMode::Udp); + assert_eq!(udp.down, CarrierMode::Udp); + assert_eq!(udp.mux, MuxMode::Disabled); + + let mixed = parse("vector://secret@example.com/udp6:2017/tcp4:2006?socks=:1080").unwrap(); + assert_eq!(mixed.portal_endpoint(), "example.com/tcp4:2006/udp6:2017"); + assert_eq!(mixed.remote.tcp.unwrap().port, 2006); + assert_eq!(mixed.remote.udp.unwrap().port, 2017); + assert_eq!(mixed.up, CarrierMode::Tcp); + assert_eq!(mixed.down, CarrierMode::Tcp); + assert_eq!(mixed.mux, MuxMode::Disabled); +} + +#[test] +fn omitted_directions_keep_independent_tcp_defaults_and_explicit_mux() { + for endpoint in ["example.com:2000", "example.com/udp6:2017/tcp4:2006"] { + for (query, up, down) in [ + ("", "tcp", "tcp"), + ("&up=udp", "udp", "tcp"), + ("&down=udp", "tcp", "udp"), + ("&up=mix", "mix", "tcp"), + ("&down=mix", "tcp", "mix"), + ] { + for (mux_query, mux) in [("", MuxMode::Disabled), ("&mux=1", MuxMode::Enabled)] { + let raw = format!("vector://secret@{endpoint}?socks=:1080{query}{mux_query}"); + let config = parse(&raw).unwrap(); + assert_eq!(config.up.to_string(), up, "{raw}"); + assert_eq!(config.down.to_string(), down, "{raw}"); + assert_eq!(config.mux, mux, "{raw}"); + assert!( + config + .effective_url() + .contains(&format!("up={up}&down={down}&mux={mux}&")), + "{raw}" + ); + } + } + } +} + +#[test] +fn policy_must_use_declared_carriers() { + for raw in [ + "vector://secret@example.com/tcp:2006?up=udp&socks=:1080", + "vector://secret@example.com/udp:2017?down=tcp&socks=:1080", + "vector://secret@example.com/tcp:2006?up=mix&socks=:1080", + ] { + assert!(parse(raw).is_err(), "accepted {raw}"); + } +} + #[test] fn tcp_pair_defaults_to_dedicated_lanes() { let config = - parse("vector://secret@example.com:2077?up=tcp&down=tcp&socks=127.0.0.1:1080").unwrap(); + parse("vector://secret@example.com:2000?up=tcp&down=tcp&socks=127.0.0.1:1080").unwrap(); assert_eq!(config.checkpoint_mode(), 0); assert_eq!(config.mux, MuxMode::Disabled); } @@ -40,7 +98,7 @@ fn parses_all_route_policies_and_preserves_checkpoint_modes() { ]; for (up, down, mode) in cases { let config = parse(&format!( - "vector://secret@example.com:2077?up={up}&down={down}&socks=:1080" + "vector://secret@example.com:2000?up={up}&down={down}&socks=:1080" )) .unwrap(); assert_eq!(config.up.to_string(), up); @@ -67,14 +125,14 @@ fn mux_is_available_for_tcp_or_mix_and_normalized_for_pure_udp() { ("mix", "mix"), ] { let config = parse(&format!( - "vector://secret@example.com:2077?up={up}&down={down}&mux=1&socks=:1080" + "vector://secret@example.com:2000?up={up}&down={down}&mux=1&socks=:1080" )) .unwrap(); assert_eq!(config.mux, MuxMode::Enabled, "up={up} down={down}"); } let config = - parse("vector://secret@example.com:2077?up=udp&down=udp&mux=1&socks=:1080").unwrap(); + parse("vector://secret@example.com:2000?up=udp&down=udp&mux=1&socks=:1080").unwrap(); assert_eq!(config.mux, MuxMode::Disabled); assert!(config.effective_url().contains("up=udp&down=udp")); assert!(config.effective_url().contains("&mux=0&")); @@ -83,7 +141,7 @@ fn mux_is_available_for_tcp_or_mix_and_normalized_for_pure_udp() { #[test] fn parses_authenticated_socks_and_preserves_plus() { let config = parse( - "vector://secret@example.com:2077?socks=user%2Bname:p%40ss%3Aword@%5B%3A%3A1%5D:1080", + "vector://secret@example.com:2000?socks=user%2Bname:p%40ss%3Aword@%5B%3A%3A1%5D:1080", ) .unwrap(); let credentials = config.socks.credentials.unwrap(); @@ -93,31 +151,30 @@ fn parses_authenticated_socks_and_preserves_plus() { #[test] fn rejects_missing_or_empty_socks() { - assert!(parse("vector://secret@example.com:2077").is_err()); - assert!(parse("vector://secret@example.com:2077?socks=").is_err()); + assert!(parse("vector://secret@example.com:2000").is_err()); + assert!(parse("vector://secret@example.com:2000?socks=").is_err()); } #[test] fn ignores_unknown_values_and_keeps_the_first_duplicate() { let config = parse( - "vector://secret@example.com:2077?wat=1&%FF=x&alpn=private/2&pool=8&up=tcp&up=udp&down=tcp&socks=:1080&socks=:1081", + "vector://secret@example.com:2000?wat=1&%FF=x&alpn=private/2&pool=8&up=tcp&up=udp&down=tcp&socks=:1080&socks=:1081", ) .unwrap(); assert_eq!(config.up, CarrierMode::Tcp); assert_eq!(config.down, CarrierMode::Tcp); - assert_eq!(config.alpn, "private/2"); assert_eq!(config.socks.port, 1080); - assert!(config.effective_url().contains("alpn=private/2")); + assert!(!config.effective_url().contains("alpn=")); assert!(!config.effective_url().contains("pool=")); } #[test] fn rejects_invalid_selected_values_but_accepts_disabled_identity_options() { - assert!(parse("vector://secret@example.com:2077?socks=:1080&up=auto").is_err()); - assert!(parse("vector://secret@example.com:2077?socks=:1080&rate=-1").is_err()); + assert!(parse("vector://secret@example.com:2000?socks=:1080&up=auto").is_err()); + assert!(parse("vector://secret@example.com:2000?socks=:1080&rate=-1").is_err()); for sni in ["", "none"] { let config = parse(&format!( - "vector://secret@example.com:2077?sni={sni}&socks=:1080" + "vector://secret@example.com:2000?sni={sni}&socks=:1080" )) .unwrap(); assert_eq!(config.sni, None); @@ -125,54 +182,74 @@ fn rejects_invalid_selected_values_but_accepts_disabled_identity_options() { } for pin in ["", "none"] { let config = parse(&format!( - "vector://secret@example.com:2077?pin={pin}&socks=:1080" + "vector://secret@example.com:2000?pin={pin}&socks=:1080" )) .unwrap(); assert_eq!(config.pin, None); assert!(config.effective_url().contains("&pin=none&")); } - let config = parse("vector://secret@example.com:2077?pin&socks=:1080").unwrap(); + let config = parse("vector://secret@example.com:2000?pin&socks=:1080").unwrap(); assert_eq!(config.pin, None); } #[test] fn effective_url_uses_canonical_order_and_prints_identity_options() { let config = parse( - "vector://secret@example.com:2077?log=debug&alpn=private&mux=1&pool=8&down=tcp&up=tcp&sni=relay.example&pin=abc&etar=2&rate=1&socks=:1080", + "vector://secret@example.com:2000?log=debug&alpn=private&mux=1&pool=8&down=tcp&up=tcp&sni=relay.example&pin=abc&etar=2&rate=1&socks=:1080", ) .unwrap(); assert_eq!( config.effective_url(), - "vector://example.com:2077?up=tcp&down=tcp&alpn=private&mux=1&sni=relay.example&pin=abc&rate=1&etar=2&socks=:1080" + "vector://example.com:2000?up=tcp&down=tcp&mux=1&sni=relay.example&pin=abc&rate=1&etar=2&morph=0&socks=:1080" ); } #[test] -fn validates_alpn_and_mux_inputs() { +fn morph_is_strict_optional_and_uses_the_first_value() { + let disabled = parse("vector://secret@example.com:2000?socks=:1080").unwrap(); + assert!(!disabled.morph); + assert!(disabled.morph_keys.is_none()); + + let enabled = parse("vector://secret@example.com:2000?morph=1&morph=0&socks=:1080").unwrap(); + assert!(enabled.morph); + assert!(enabled.morph_keys.is_some()); + assert!(enabled.effective_url().contains("&morph=1&")); + + for value in ["", "2", "true"] { + assert!( + parse(&format!( + "vector://secret@example.com:2000?morph={value}&socks=:1080" + )) + .is_err() + ); + } +} + +#[test] +fn ignores_removed_alpn_and_validates_mux_inputs() { for raw in [ - "vector://secret@example.com:2077?socks=:1080&alpn=", - "vector://secret@example.com:2077?socks=:1080&mux=", - "vector://secret@example.com:2077?socks=:1080&mux=2", - "vector://secret@example.com:2077?socks=:1080&mux=true", - "vector://secret@example.com:2077?socks=:1080&mux=-1", + "vector://secret@example.com:2000?socks=:1080&mux=", + "vector://secret@example.com:2000?socks=:1080&mux=2", + "vector://secret@example.com:2000?socks=:1080&mux=true", + "vector://secret@example.com:2000?socks=:1080&mux=-1", ] { assert!(parse(raw).is_err(), "URL unexpectedly accepted: {raw}"); } - let oversized = "a".repeat(256); - assert!( - parse(&format!( - "vector://secret@example.com:2077?socks=:1080&alpn={oversized}" + for alpn in [String::new(), "a".repeat(256)] { + let config = parse(&format!( + "vector://secret@example.com:2000?socks=:1080&alpn={alpn}" )) - .is_err() - ); + .unwrap(); + assert!(!config.effective_url().contains("alpn=")); + } } #[test] fn preserves_pin_without_early_validation() { for pin in ["abc", "ABCDEF", "not-a-fingerprint"] { let config = parse(&format!( - "vector://secret@example.com:2077?pin={pin}&socks=:1080" + "vector://secret@example.com:2000?pin={pin}&socks=:1080" )) .unwrap(); assert_eq!(config.pin.as_deref(), Some(pin)); @@ -181,18 +258,18 @@ fn preserves_pin_without_early_validation() { #[test] fn rejects_invalid_authority_shape() { - assert!(parse("vector://example.com:2077?socks=:1080").is_err()); - assert!(parse("vector://secret:password@example.com:2077?socks=:1080").is_err()); + assert!(parse("vector://example.com:2000?socks=:1080").is_err()); + assert!(parse("vector://secret:password@example.com:2000?socks=:1080").is_err()); assert!(parse("vector://secret@example.com?socks=:1080").is_err()); - assert!(parse("vector://secret@example.com:2077/?socks=:1080").is_err()); - assert!(parse("vector://secret@example.com:2077/path?socks=:1080").is_err()); + assert!(parse("vector://secret@example.com:2000/?socks=:1080").is_err()); + assert!(parse("vector://secret@example.com:2000/path?socks=:1080").is_err()); } #[test] fn normalizes_ipv6_portal_authority() { - let config = parse("vector://secret@[::1]:2077?socks=127.0.0.1:1080").unwrap(); - assert_eq!(config.remote_host, "::1"); - assert_eq!(config.portal_endpoint(), "[::1]:2077"); + let config = parse("vector://secret@[::1]:2000?socks=127.0.0.1:1080").unwrap(); + assert_eq!(config.remote.host, "::1"); + assert_eq!(config.portal_endpoint(), "[::1]:2000"); } #[test] @@ -213,6 +290,50 @@ fn upstream_authority_decodes_reserved_key_bytes_and_ipv6() { ); } +#[test] +fn upstream_morph_derives_from_the_nested_shared_key() { + let query = HashMap::from([("morph".to_owned(), "1".to_owned())]); + let (config, _) = PortalClientConfig::from_upstream_authority( + "upstream-key@origin.example:2080", + &query, + "auto", + ) + .unwrap(); + let actual = config.morph_keys.unwrap().udp_key(); + + assert_eq!(actual, MorphKeys::derive(b"upstream-key").udp_key()); + assert_ne!(actual, MorphKeys::derive(b"outer-key").udp_key()); +} + +#[test] +fn upstream_authority_decodes_the_shared_key_exactly_once() { + let query = HashMap::new(); + let (_, credentials) = PortalClientConfig::from_upstream_authority( + "part%2540key@origin.example/udp:2080", + &query, + "auto", + ) + .unwrap(); + assert_eq!( + credentials, + crate::protocol::Credentials::from_shared_key(b"part%40key").unwrap() + ); +} + +#[test] +fn upstream_authority_accepts_explicit_carriers() { + let query = HashMap::new(); + let (config, _) = PortalClientConfig::from_upstream_authority( + "secret@origin.example/tcp6:2006", + &query, + "auto", + ) + .unwrap(); + assert_eq!(config.endpoint(), "origin.example/tcp6:2006"); + assert_eq!(config.up, CarrierMode::Tcp); + assert_eq!(config.down, CarrierMode::Tcp); +} + #[test] fn upstream_authority_requires_unambiguous_key_endpoint_separator() { let query = HashMap::new(); @@ -228,3 +349,44 @@ fn upstream_authority_requires_unambiguous_key_endpoint_separator() { ); } } + +#[test] +fn upstream_authority_rejects_every_invalid_endpoint_shape() { + let query = HashMap::new(); + for (authority, expected) in [ + ("secret@*:2000", "wildcard host is only valid"), + ( + "secret@origin.example:2000/tcp:2006", + "choose either HOST:PORT", + ), + ("secret@origin.example/tcp:2006/", "trailing slash"), + ( + "secret@origin.example/tcp:2006/tcp6:2006", + "TCP carrier is declared more than once", + ), + ("secret@origin.example/udp:0", "1..=65535"), + ("secret@origin.example/sctp:2000", "unknown carrier"), + ( + "secret@192.0.2.1/udp6:2017", + "address family does not match", + ), + ( + "secret@origin.example/tcp:2006?inner=1", + "expected shared-key and one endpoint", + ), + ( + "secret@origin.example/tcp:2006#fragment", + "expected shared-key and one endpoint", + ), + ("bad%GG@origin.example/tcp:2006", "malformed percent escape"), + ] { + let error = PortalClientConfig::from_upstream_authority(authority, &query, "auto") + .unwrap_err() + .to_string(); + assert!(error.contains(expected), "{authority} returned {error:?}"); + assert!( + !error.contains("secret@"), + "error leaked the next shared key" + ); + } +} diff --git a/src/tests/vector/flow.rs b/src/tests/vector/flow.rs index 1237606..9429818 100644 --- a/src/tests/vector/flow.rs +++ b/src/tests/vector/flow.rs @@ -18,7 +18,7 @@ use crate::vector::config::VectorConfig; fn test_portal_client() -> Arc { let url = - Url::parse("vector://secret@127.0.0.1:2077?up=mix&down=mix&socks=127.0.0.1:1080").unwrap(); + Url::parse("vector://secret@127.0.0.1:2000?up=mix&down=mix&socks=127.0.0.1:1080").unwrap(); let config = VectorConfig::from_url(&url).unwrap(); let credentials = Credentials::new(&url).unwrap(); PortalClient::with_session_id( @@ -64,7 +64,7 @@ async fn cold_lane_coalesces_auth_flow_and_target() { assert_eq!(&wire[..AUTH_FRAME_LEN], &auth); assert_eq!( &wire[AUTH_FRAME_LEN..AUTH_FRAME_LEN + FLOW_HEADER_LEN], - &write_flow_header(header) + &write_flow_header(header).unwrap() ); assert_eq!(&wire[AUTH_FRAME_LEN + FLOW_HEADER_LEN..], encoded_target); } @@ -89,7 +89,7 @@ async fn cold_attach_lane_coalesces_auth_and_flow_header() { let mut wire = Vec::new(); reader.read_to_end(&mut wire).await.unwrap(); assert_eq!(&wire[..AUTH_FRAME_LEN], &auth); - assert_eq!(&wire[AUTH_FRAME_LEN..], &write_flow_header(header)); + assert_eq!(&wire[AUTH_FRAME_LEN..], &write_flow_header(header).unwrap()); } #[tokio::test] diff --git a/src/tests/vector/flow_id.rs b/src/tests/vector/flow_id.rs index ac03f4e..0cc2d4d 100644 --- a/src/tests/vector/flow_id.rs +++ b/src/tests/vector/flow_id.rs @@ -1,12 +1,35 @@ use super::*; +#[test] +fn exhausted_allocator_recovers_only_released_ids() { + let allocator = FlowIdAllocator::new(); + let first = allocator.allocate_with_limit(3).unwrap(); + let second = allocator.allocate_with_limit(3).unwrap(); + let third = allocator.allocate_with_limit(3).unwrap(); + assert!( + allocator + .allocate_with_limit(3) + .unwrap_err() + .to_string() + .contains("space exhausted") + ); + let released = second.id(); + drop(second); + let reused = allocator.allocate_with_limit(3).unwrap(); + assert_eq!(reused.id(), released); + assert_ne!(reused.id(), first.id()); + assert_ne!(reused.id(), third.id()); + assert!(allocator.allocate_with_limit(3).is_err()); +} + #[test] fn allocator_never_reuses_an_active_id() { - let allocator = FlowIdAllocator::new(2); + let allocator = FlowIdAllocator::new(); let first = allocator.allocate().unwrap(); let second = allocator.allocate().unwrap(); assert_ne!(first.id(), second.id()); - assert!(allocator.allocate().is_err()); + let extra: Vec<_> = (0..4096).map(|_| allocator.allocate().unwrap()).collect(); + assert_eq!(extra.len(), 4096); let released = first.id(); drop(first); let third = allocator.allocate().unwrap(); @@ -16,10 +39,12 @@ fn allocator_never_reuses_an_active_id() { #[test] fn allocator_skips_zero_at_wrap() { - let allocator = FlowIdAllocator::new(2); - allocator.next.store(u32::MAX, Ordering::Relaxed); + let allocator = FlowIdAllocator::new(); + let first = allocator.allocate().unwrap(); + assert_eq!(first.id(), 1); + allocator.next.store(MAX_FLOW_ID, Ordering::Relaxed); let max = allocator.allocate().unwrap(); let wrapped = allocator.allocate().unwrap(); - assert_eq!(max.id(), u32::MAX); - assert_ne!(wrapped.id(), 0); + assert_eq!(max.id(), MAX_FLOW_ID); + assert_eq!(wrapped.id(), 2); } diff --git a/src/tests/vector/session.rs b/src/tests/vector/session.rs index 1f8bc64..10a0cb9 100644 --- a/src/tests/vector/session.rs +++ b/src/tests/vector/session.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: GPL-3.0-only #[test] -fn quic_flow_control_matches_authenticated_portal_capacity() { - let flow_control = crate::transport::quic_flow_control().unwrap(); +fn transport_flow_control_matches_authenticated_portal_capacity() { + let flow_control = crate::transport::transport_flow_control().unwrap(); assert!(flow_control.stream_receive_window <= flow_control.connection_receive_window); assert!(flow_control.send_window >= u64::from(flow_control.stream_receive_window)); } diff --git a/src/tests/vector/session/tls.rs b/src/tests/vector/session/tls.rs index 62abd32..29fcccb 100644 --- a/src/tests/vector/session/tls.rs +++ b/src/tests/vector/session/tls.rs @@ -4,73 +4,140 @@ use super::*; use tokio::io::AsyncReadExt; -#[tokio::test] -async fn shard_selection_stops_at_four_active_flows() { +fn slot(handle: MuxHandle) -> Arc { + let slot = Arc::new(TlsMux::default()); + assert!(slot.handle.set(handle).is_ok()); + slot +} + +async fn carrier(pressured: bool) -> (MuxHandle, MuxHandle, crate::mux::Incoming, Vec) { let (left, right) = tokio::io::duplex(1 << 20); - let (handle, _) = MuxHandle::start(left, MuxConfig::default()).unwrap(); - let (_peer, mut incoming) = MuxHandle::start(right, MuxConfig::default()).unwrap(); + let config = MuxConfig { + stream_window_bytes: 4 << 20, + connection_window_bytes: 8 << 20, + outbound_frames: 512, + ..MuxConfig::default() + }; + let (handle, _) = MuxHandle::start(left, config).unwrap(); + let (peer, mut incoming) = MuxHandle::start(right, config).unwrap(); let mut streams = Vec::new(); - let mut peers = Vec::new(); - - for flow_id in 1..TLS_MUX_FLOWS_PER_SHARD as u32 { - streams.push(handle.open_stream(flow_id).await.unwrap()); - peers.push(incoming.accept().await.unwrap().unwrap()); + for id in 1..=2 { + let mut stream = handle.open_stream(id).await.unwrap(); + streams.push(incoming.accept().await.unwrap().unwrap()); + if pressured { + stream.write_all(&vec![1; 7 << 19]).await.unwrap(); + stream.flush().await.unwrap(); + } + streams.push(stream); } + (handle, peer, incoming, streams) +} + +#[tokio::test] +async fn cold_reservations_balance_across_eight_connecting_slots() { + let mut pool = Vec::new(); + let pending: Vec<_> = (0..16).map(|_| reserve_mux(&mut pool)).collect(); + assert_eq!(pool.len(), 8); assert!( - select_available_mux(std::slice::from_ref(&handle)) - .unwrap() - .same_carrier(&handle) + pool.iter() + .all(|slot| slot.pending.load(Ordering::Relaxed) == 2) ); - - streams.push( - handle - .open_stream(TLS_MUX_FLOWS_PER_SHARD as u32) - .await - .unwrap(), + drop(pending); + assert!( + pool.iter() + .all(|slot| slot.pending.load(Ordering::Relaxed) == 0) ); - peers.push(incoming.accept().await.unwrap().unwrap()); - assert!(select_available_mux(std::slice::from_ref(&handle)).is_none()); } #[tokio::test] -async fn shard_selection_uses_the_least_loaded_carrier() { - let (left_a, right_a) = tokio::io::duplex(1 << 20); - let (handle_a, _) = MuxHandle::start(left_a, MuxConfig::default()).unwrap(); - let (_peer_a, mut incoming_a) = MuxHandle::start(right_a, MuxConfig::default()).unwrap(); - let (left_b, right_b) = tokio::io::duplex(1 << 20); - let (handle_b, _) = MuxHandle::start(left_b, MuxConfig::default()).unwrap(); - let (_peer_b, mut incoming_b) = MuxHandle::start(right_b, MuxConfig::default()).unwrap(); - - let _stream_a1 = handle_a.open_stream(1).await.unwrap(); - let _peer_a1 = incoming_a.accept().await.unwrap().unwrap(); - let _stream_a2 = handle_a.open_stream(2).await.unwrap(); - let _peer_a2 = incoming_a.accept().await.unwrap().unwrap(); - let _stream_b = handle_b.open_stream(3).await.unwrap(); - let _peer_b = incoming_b.accept().await.unwrap().unwrap(); - - let selected = select_available_mux(&[handle_a, handle_b.clone()]).unwrap(); - assert!(selected.same_carrier(&handle_b)); +async fn idle_carrier_is_reused_before_new_connections() { + let (handle, peer, _incoming, streams) = carrier(false).await; + drop(streams); + let mut pool = vec![slot(handle.clone())]; + let selected = reserve_mux(&mut pool); + assert_eq!(pool.len(), 1); + assert!(selected.0.handle.get().unwrap().same_carrier(&handle)); + handle.close(); + peer.close(); } #[tokio::test] -async fn closing_one_shard_does_not_affect_another() { - let (left_a, right_a) = tokio::io::duplex(1 << 20); - let (handle_a, _) = MuxHandle::start(left_a, MuxConfig::default()).unwrap(); - let (_peer_a, mut incoming_a) = MuxHandle::start(right_a, MuxConfig::default()).unwrap(); - let (left_b, right_b) = tokio::io::duplex(1 << 20); - let (handle_b, _) = MuxHandle::start(left_b, MuxConfig::default()).unwrap(); - let (_peer_b, mut incoming_b) = MuxHandle::start(right_b, MuxConfig::default()).unwrap(); - - let mut stream_a = handle_a.open_stream(1).await.unwrap(); - let _peer_stream_a = incoming_a.accept().await.unwrap().unwrap(); - let mut stream_b = handle_b.open_stream(2).await.unwrap(); - let mut peer_stream_b = incoming_b.accept().await.unwrap().unwrap(); +async fn full_pool_prefers_lower_pressure_and_still_transfers_new_flows() { + let mut pool = Vec::new(); + let mut peers = Vec::new(); + let mut streams = Vec::new(); + let mut incoming = Vec::new(); + for index in 0..8 { + let (handle, peer, receiver, held) = carrier(index != 7).await; + pool.push(slot(handle)); + peers.push(peer); + streams.extend(held); + incoming.push(receiver); + } + let selected = reserve_mux(&mut pool); + assert!(Arc::ptr_eq(&selected.0, &pool[7])); + let handle = selected.0.handle.get().unwrap(); + let mut stream = handle.open_stream(99).await.unwrap(); + let mut accepted = incoming[7].accept().await.unwrap().unwrap(); + stream.write_all(b"new").await.unwrap(); + let mut bytes = [0; 3]; + accepted.read_exact(&mut bytes).await.unwrap(); + assert_eq!(&bytes, b"new"); + assert_eq!(pool.len(), 8); + for slot in &pool { + slot.handle.get().unwrap().close(); + } + for peer in &peers { + peer.close(); + } + drop((streams, stream)); +} - handle_a.close(); - assert!(stream_a.write_all(b"closed").await.is_err()); +#[tokio::test] +async fn cancelling_initializer_releases_reservation_and_allows_retry() { + let mut pool = Vec::new(); + let pending = reserve_mux(&mut pool); + let (started, ready) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let _pending = pending; + _pending + .0 + .handle + .get_or_try_init(|| async { + let _ = started.send(()); + std::future::pending::>().await + }) + .await + .map(|_| ()) + }); + ready.await.unwrap(); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert_eq!(pool[0].pending.load(Ordering::Relaxed), 0); + let retry = reserve_mux(&mut pool); + assert_eq!(pool.len(), 1); + let (handle, peer, _incoming, streams) = carrier(false).await; + retry + .0 + .handle + .get_or_try_init(|| async { Ok::<_, anyhow::Error>(handle.clone()) }) + .await + .unwrap(); + assert!(retry.0.handle.get().unwrap().same_carrier(&handle)); + handle.close(); + peer.close(); + drop(streams); +} - stream_b.write_all(b"live").await.unwrap(); - let mut payload = [0_u8; 4]; - peer_stream_b.read_exact(&mut payload).await.unwrap(); - assert_eq!(&payload, b"live"); +#[tokio::test] +async fn closed_carrier_is_replaced_with_a_reusable_slot() { + let (handle, peer, _incoming, streams) = carrier(false).await; + let mut pool = vec![slot(handle.clone())]; + let old = pool[0].clone(); + handle.close(); + let pending = reserve_mux(&mut pool); + assert_eq!(pool.len(), 1); + assert!(!Arc::ptr_eq(&pending.0, &old)); + peer.close(); + drop(streams); } diff --git a/src/tests/vector/socks_server.rs b/src/tests/vector/socks_server.rs index 52ba01c..bfd73bf 100644 --- a/src/tests/vector/socks_server.rs +++ b/src/tests/vector/socks_server.rs @@ -38,6 +38,41 @@ fn source_endpoint_locks_first_port() { )); } +#[test] +fn socks_client_resource_admission_is_process_wide_and_reusable() { + let vector = Vector::new( + Url::parse("vector://secret@127.0.0.1:2000?socks=127.0.0.1:1080").unwrap(), + Logger::new(LogLevel::None, false), + ) + .unwrap(); + let limit = vector.inner.socks_client_admission.available_permits(); + let mut held = (0..limit) + .map(|_| try_admit_client(&vector.inner).unwrap()) + .collect::>(); + assert!(try_admit_client(&vector.inner).is_none()); + + held.pop(); + assert!(try_admit_client(&vector.inner).is_some()); +} + +#[test] +fn udp_target_resource_admission_is_held_for_the_target_lifetime() { + let vector = Vector::new( + Url::parse("vector://secret@127.0.0.1:2000?socks=127.0.0.1:1080").unwrap(), + Logger::new(LogLevel::None, false), + ) + .unwrap(); + let admission = &vector.inner.socks_udp_target_admission; + let limit = admission.available_permits(); + let mut held = (0..limit) + .map(|_| try_admit_udp_target(admission).unwrap()) + .collect::>(); + assert!(try_admit_udp_target(admission).is_none()); + + held.pop(); + assert!(try_admit_udp_target(admission).is_some()); +} + #[tokio::test] async fn pending_target_setup_does_not_block_control_shutdown() { let portal = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/src/tests/vector/tls.rs b/src/tests/vector/tls.rs index 97e8039..7c63315 100644 --- a/src/tests/vector/tls.rs +++ b/src/tests/vector/tls.rs @@ -16,7 +16,7 @@ fn config(raw: &str) -> PortalClientConfig { } #[tokio::test] -async fn client_negotiates_the_configured_alpn() { +async fn client_prefers_fixed_v2_alpn() { let generated = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); let certificate: CertificateDer<'static> = generated.cert.into(); let key = PrivateKeyDer::from(PrivatePkcs8KeyDer::from( @@ -29,7 +29,7 @@ async fn client_negotiates_the_configured_alpn() { .with_no_client_auth() .with_single_cert(vec![certificate], key) .unwrap(); - server.alpn_protocols = vec![b"private/2".to_vec()]; + server.alpn_protocols = vec![b"nw2".to_vec()]; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let endpoint = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { @@ -38,9 +38,13 @@ async fn client_negotiates_the_configured_alpn() { }); let raw = format!("vector://secret@{endpoint}?alpn=private/2&socks=127.0.0.1:1080"); - let (_, _) = ClientTls::new(&config(&raw)) + let _ = ClientTls::new(&config(&raw)) .unwrap() - .connect_tcp(&endpoint.to_string(), "auto") + .connect_tcp( + &endpoint.to_string(), + "auto", + crate::common::AddressFamily::Any, + ) .await .unwrap(); server_task.await.unwrap().unwrap(); @@ -49,11 +53,11 @@ async fn client_negotiates_the_configured_alpn() { #[test] fn missing_sni_uses_unverified_policy() { let tls = ClientTls::new(&config( - "vector://secret@127.0.0.1:2077?socks=127.0.0.1:1080", + "vector://secret@127.0.0.1:2000?socks=127.0.0.1:1080", )) .unwrap(); assert_eq!( - vector_config("vector://secret@127.0.0.1:2077?socks=127.0.0.1:1080").sni, + vector_config("vector://secret@127.0.0.1:2000?socks=127.0.0.1:1080").sni, None ); assert_eq!(tls.quic_server_name(), "127.0.0.1"); @@ -62,13 +66,13 @@ fn missing_sni_uses_unverified_policy() { #[test] fn ipv6_authority_builds_an_ip_server_name() { - let tls = ClientTls::new(&config("vector://secret@[::1]:2077?socks=127.0.0.1:1080")).unwrap(); + let tls = ClientTls::new(&config("vector://secret@[::1]:2000?socks=127.0.0.1:1080")).unwrap(); assert_eq!(tls.quic_server_name(), "::1"); } #[test] fn explicit_sni_enables_system_verification() { - let config = config("vector://secret@127.0.0.1:2077?sni=example.com&socks=127.0.0.1:1080"); + let config = config("vector://secret@127.0.0.1:2000?sni=example.com&socks=127.0.0.1:1080"); let tls = ClientTls::new(&config).unwrap(); assert_eq!(config.sni.as_deref(), Some("example.com")); assert_eq!(tls.quic_server_name(), "example.com"); @@ -97,7 +101,7 @@ async fn test_pinned_handshake(pin: TestPin, sni: Option<&str>) -> Result<()> { .with_no_client_auth() .with_single_cert(vec![certificate], key) .unwrap(); - server.alpn_protocols = vec![b"now/1".to_vec()]; + server.alpn_protocols = vec![b"nw2".to_vec()]; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let endpoint = listener.local_addr().unwrap(); let server_task = tokio::spawn(async move { @@ -122,13 +126,96 @@ async fn test_pinned_handshake(pin: TestPin, sni: Option<&str>) -> Result<()> { raw.push_str("socks=127.0.0.1:1080"); let result = ClientTls::new(&config(&raw))? - .connect_tcp(&endpoint.to_string(), "auto") + .connect_tcp( + &endpoint.to_string(), + "auto", + crate::common::AddressFamily::Any, + ) .await .map(|_| ()); let _ = tokio::time::timeout(Duration::from_secs(1), server_task).await; result } +#[tokio::test] +async fn tcp_server_without_nw2_is_rejected() { + let generated = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); + let certificate: CertificateDer<'static> = generated.cert.into(); + let key = PrivateKeyDer::from(PrivatePkcs8KeyDer::from( + generated.signing_key.serialize_der(), + )); + let mut server = + rustls::ServerConfig::builder_with_provider(Arc::new(ring::default_provider())) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert(vec![certificate], key) + .unwrap(); + server.alpn_protocols = vec![b"now/1".to_vec()]; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let _ = TlsAcceptor::from(Arc::new(server)).accept(stream).await; + }); + let raw = format!("vector://secret@{endpoint}?socks=127.0.0.1:1080"); + assert!( + ClientTls::new(&config(&raw)) + .unwrap() + .connect_tcp( + &endpoint.to_string(), + "auto", + crate::common::AddressFamily::Any + ) + .await + .is_err() + ); +} + +async fn negotiate_quic(server_alpns: Vec>) -> Result<()> { + let generated = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).unwrap(); + let certificate: CertificateDer<'static> = generated.cert.into(); + let key = PrivateKeyDer::from(PrivatePkcs8KeyDer::from( + generated.signing_key.serialize_der(), + )); + let mut rustls_server = + rustls::ServerConfig::builder_with_provider(Arc::new(ring::default_provider())) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert(vec![certificate], key) + .unwrap(); + rustls_server.alpn_protocols = server_alpns; + let quic_server = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_server).unwrap(); + let server = quinn::Endpoint::server( + quinn::ServerConfig::with_crypto(Arc::new(quic_server)), + "127.0.0.1:0".parse().unwrap(), + ) + .unwrap(); + let address = server.local_addr().unwrap(); + let server_task = tokio::spawn(async move { + let incoming = server.accept().await.unwrap(); + incoming.await + }); + + let raw = format!("vector://secret@{address}?socks=127.0.0.1:1080"); + let tls = ClientTls::new(&config(&raw))?; + let mut client = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap())?; + client.set_default_client_config(tls.quic_client_config()?); + let connection = client.connect(address, &tls.quic_server_name())?.await?; + require_quic_nw2(&connection)?; + connection.close(quinn::VarInt::from_u32(0), b""); + let _ = server_task.await; + Ok(()) +} + +#[tokio::test] +async fn quic_requires_nw2() { + negotiate_quic(vec![b"nw2".to_vec()]).await.unwrap(); + assert!(negotiate_quic(vec![b"now/1".to_vec()]).await.is_err()); + assert!(negotiate_quic(vec![b"private/2".to_vec()]).await.is_err()); +} + #[tokio::test] async fn exact_pin_overrides_sni_certificate_verification() { test_pinned_handshake(TestPin::Exact, Some("wrong.example")) diff --git a/src/transport/buffers.rs b/src/transport/buffers.rs index 827bc31..9f19f7e 100644 --- a/src/transport/buffers.rs +++ b/src/transport/buffers.rs @@ -94,6 +94,12 @@ impl AsMut<[u8]> for BufferLease { } } +impl AsRef<[u8]> for BufferLease { + fn as_ref(&self) -> &[u8] { + self.as_slice() + } +} + impl Drop for BufferLease { fn drop(&mut self) { let Some(buffer) = self.buffer.take() else { diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 812f06a..ece74ce 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -4,11 +4,19 @@ //! Transport support primitives for buffers, rate limits, and counters. mod buffers; +mod morph; +mod owned_io; mod quic; mod rate; mod stats; pub use buffers::{BufferLease, Buffers}; -pub(crate) use quic::quic_flow_control; +pub(crate) use morph::{ + MorphKeys, MorphTcpStream, configure_morph_mtu, morph_endpoint_config, wrap_morph_udp_socket, +}; +pub(crate) use owned_io::{ + AsyncReadAny, AsyncWriteAny, read_owned, read_owned_from, write_owned, write_owned_to, +}; +pub(crate) use quic::{TransportFlowControl, transport_flow_control}; pub use rate::{RateLimiter, TokenBucket}; pub use stats::Stats; diff --git a/src/transport/morph.rs b/src/transport/morph.rs new file mode 100644 index 0000000..e432867 --- /dev/null +++ b/src/transport/morph.rs @@ -0,0 +1,86 @@ +// Copyright (C) 2026 NodePassProject +// SPDX-License-Identifier: GPL-3.0-only + +//! Optional keyed wire transform below TLS and QUIC. + +use std::fmt; +use std::io; + +use hmac::{Hmac, KeyInit as HmacKeyInit, Mac}; +use sha2::Sha256; + +mod tcp; +mod udp; + +pub(crate) use tcp::MorphTcpStream; +pub(crate) use udp::{configure_morph_mtu, morph_endpoint_config, wrap_morph_udp_socket}; + +const NONCE_LEN: usize = 12; +const MORPH_ROOT_SALT: &[u8] = b"nowhere/morph"; +const TCP_C2S_INFO: &[u8] = b"tcp c2s"; +const TCP_S2C_INFO: &[u8] = b"tcp s2c"; +const UDP_INFO: &[u8] = b"udp"; + +type MorphKey = [u8; 32]; + +#[derive(Clone)] +pub(crate) struct MorphKeys { + tcp_c2s: MorphKey, + tcp_s2c: MorphKey, + udp: MorphKey, +} + +impl fmt::Debug for MorphKeys { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MorphKeys(REDACTED)") + } +} + +impl MorphKeys { + pub(crate) fn from_url(url: &url::Url) -> anyhow::Result { + Ok(Self::derive( + &crate::protocol::Credentials::decode_shared_key(url)?, + )) + } + + pub(crate) fn derive(shared_key: &[u8]) -> Self { + let root = hmac_sha256(MORPH_ROOT_SALT, shared_key); + Self { + tcp_c2s: hkdf_expand_one(root, TCP_C2S_INFO), + tcp_s2c: hkdf_expand_one(root, TCP_S2C_INFO), + udp: hkdf_expand_one(root, UDP_INFO), + } + } + + pub(crate) fn udp_key(&self) -> MorphKey { + self.udp + } +} + +fn hmac_sha256(key: &[u8], data: &[u8]) -> MorphKey { + let mut mac = + as HmacKeyInit>::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(data); + mac.finalize().into_bytes().into() +} + +fn hkdf_expand_one(root: MorphKey, info: &[u8]) -> MorphKey { + let mut mac = + as HmacKeyInit>::new_from_slice(&root).expect("HMAC accepts a 32-byte key"); + mac.update(info); + mac.update(&[1]); + mac.finalize().into_bytes().into() +} + +fn exhausted() -> io::Error { + io::Error::other("Morph TCP keystream exhausted") +} + +#[cfg(test)] +use tcp::apply_at; +#[cfg(test)] +use udp::{MorphUdpSocket, UdpBuffers}; + +#[cfg(test)] +#[path = "../tests/transport/morph.rs"] +mod tests; diff --git a/src/transport/morph/tcp.rs b/src/transport/morph/tcp.rs new file mode 100644 index 0000000..fda2083 --- /dev/null +++ b/src/transport/morph/tcp.rs @@ -0,0 +1,330 @@ +// Copyright (C) 2026 NodePassProject +// SPDX-License-Identifier: GPL-3.0-only + +use std::fmt; +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll, Waker}; + +use chacha20::ChaCha20; +use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +use super::{MorphKey, MorphKeys, NONCE_LEN, exhausted}; + +const TCP_STREAM_LIMIT: u64 = (1u64 << 38) - 64; + +#[derive(Clone, Copy)] +enum TcpRole { + Client, + Server, +} + +pub(super) struct TcpMorph { + pub(super) read_key: MorphKey, + pub(super) write_key: MorphKey, + pub(super) read_cipher: Option, + pub(super) write_cipher: Option, + read_offset: u64, + write_offset: u64, + pub(super) prefix: [u8; NONCE_LEN], + read_prefix_pos: usize, + write_prefix_pos: usize, + write_waiter: Option, + pub(super) write_buffer: Vec, +} + +pub(crate) struct MorphTcpStream { + inner: S, + keys: Option, + pub(super) morph: Option, +} + +impl fmt::Debug for MorphTcpStream +where + S: fmt::Debug, +{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MorphTcpStream") + .field("inner", &self.inner) + .field("enabled", &self.keys.is_some()) + .finish() + } +} + +impl MorphTcpStream { + pub(crate) fn client(inner: S, keys: Option) -> io::Result { + let mut stream = Self { + inner, + keys, + morph: None, + }; + if stream.keys.is_some() { + let mut nonce = [0u8; NONCE_LEN]; + getrandom::fill(&mut nonce).map_err(io::Error::other)?; + stream.morph = Some(stream.new_state(nonce, TcpRole::Client)); + } + Ok(stream) + } + + pub(crate) fn server(inner: S, keys: Option) -> Self { + let mut stream = Self { + inner, + keys, + morph: None, + }; + if stream.keys.is_some() { + stream.morph = Some(stream.new_state([0; NONCE_LEN], TcpRole::Server)); + } + stream + } + + fn new_state(&self, nonce: [u8; NONCE_LEN], role: TcpRole) -> TcpMorph { + let keys = self.keys.as_ref().expect("Morph state requires keys"); + let (read_key, write_key) = match role { + TcpRole::Client => (&keys.tcp_s2c, &keys.tcp_c2s), + TcpRole::Server => (&keys.tcp_c2s, &keys.tcp_s2c), + }; + TcpMorph { + read_key: *read_key, + write_key: *write_key, + read_cipher: match role { + TcpRole::Client => Some(ChaCha20::new(read_key.into(), (&nonce).into())), + TcpRole::Server => None, + }, + write_cipher: match role { + TcpRole::Client => Some(ChaCha20::new(write_key.into(), (&nonce).into())), + TcpRole::Server => None, + }, + read_offset: 0, + write_offset: 0, + prefix: nonce, + read_prefix_pos: match role { + TcpRole::Client => NONCE_LEN, + TcpRole::Server => 0, + }, + write_prefix_pos: match role { + TcpRole::Client => 0, + TcpRole::Server => NONCE_LEN, + }, + write_waiter: None, + write_buffer: Vec::new(), + } + } + + pub(crate) fn get_ref(&self) -> &S { + &self.inner + } +} + +#[cfg(test)] +pub(super) fn apply_at( + key: &MorphKey, + nonce: &[u8; NONCE_LEN], + offset: u64, + bytes: &mut [u8], +) -> io::Result<()> { + let mut cipher = ChaCha20::new(key.into(), nonce.into()); + cipher.try_seek(offset).map_err(|_| exhausted())?; + cipher.try_apply_keystream(bytes).map_err(|_| exhausted()) +} + +impl AsyncRead for MorphTcpStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if buf.remaining() == 0 { + return Poll::Ready(Ok(())); + } + if self.keys.is_none() { + return Pin::new(&mut self.inner).poll_read(cx, buf); + } + if self.morph.as_ref().unwrap().read_prefix_pos < NONCE_LEN { + while self.morph.as_ref().unwrap().read_prefix_pos < NONCE_LEN { + let filled = self.morph.as_ref().unwrap().read_prefix_pos; + let mut scratch = [0u8; NONCE_LEN]; + let mut nonce_buf = ReadBuf::new(&mut scratch[..NONCE_LEN - filled]); + match Pin::new(&mut self.inner).poll_read(cx, &mut nonce_buf) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) if nonce_buf.filled().is_empty() => { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "truncated Morph TCP nonce", + ))); + } + Poll::Ready(Ok(())) => { + let count = nonce_buf.filled().len(); + let state = self.morph.as_mut().unwrap(); + state.prefix[filled..filled + count].copy_from_slice(nonce_buf.filled()); + state.read_prefix_pos += count; + } + } + } + if let Some(waiter) = self.morph.as_mut().unwrap().write_waiter.take() { + waiter.wake(); + } + let state = self.morph.as_mut().unwrap(); + state.read_cipher = Some(ChaCha20::new( + (&state.read_key).into(), + (&state.prefix).into(), + )); + state.write_cipher = Some(ChaCha20::new( + (&state.write_key).into(), + (&state.prefix).into(), + )); + } + let remaining = TCP_STREAM_LIMIT.saturating_sub(self.morph.as_ref().unwrap().read_offset); + if remaining == 0 && buf.remaining() != 0 { + return Poll::Ready(Err(exhausted())); + } + let before = buf.filled().len(); + let allowed = usize::try_from(remaining.min(buf.remaining() as u64)).unwrap(); + let unfilled = buf.initialize_unfilled_to(allowed); + let mut inner_buf = ReadBuf::new(unfilled); + match Pin::new(&mut self.inner).poll_read(cx, &mut inner_buf) { + Poll::Ready(Ok(())) => { + let count = inner_buf.filled().len(); + let state = self.morph.as_mut().unwrap(); + state + .read_cipher + .as_mut() + .expect("Morph read cipher initialized") + .try_apply_keystream(&mut inner_buf.filled_mut()[..count]) + .map_err(|_| exhausted())?; + state.read_offset += count as u64; + buf.advance(count); + debug_assert_eq!(buf.filled().len(), before + count); + Poll::Ready(Ok(())) + } + other => other, + } + } +} + +pub(crate) trait MorphWriteReady { + fn poll_morph_write_ready(&self, cx: &mut Context<'_>) -> Poll>; +} + +impl MorphWriteReady for tokio::net::TcpStream { + fn poll_morph_write_ready(&self, cx: &mut Context<'_>) -> Poll> { + self.poll_write_ready(cx) + } +} + +impl AsyncWrite for MorphTcpStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + if input.is_empty() { + return Poll::Ready(Ok(0)); + } + if self.keys.is_none() { + return Pin::new(&mut self.inner).poll_write(cx, input); + } + let this = self.as_mut().get_mut(); + if this + .morph + .as_ref() + .expect("Morph state initialized") + .read_prefix_pos + < NONCE_LEN + { + this.morph.as_mut().unwrap().write_waiter = Some(cx.waker().clone()); + return Poll::Pending; + } + let prefix_pos = this + .morph + .as_ref() + .expect("Morph state initialized") + .write_prefix_pos; + if prefix_pos < NONCE_LEN { + let prefix = this.morph.as_ref().unwrap().prefix; + match Pin::new(&mut this.inner).poll_write(cx, &prefix[prefix_pos..]) { + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(n)) => this.morph.as_mut().unwrap().write_prefix_pos += n, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Pending => return Poll::Pending, + } + if this.morph.as_ref().unwrap().write_prefix_pos < NONCE_LEN { + cx.waker().wake_by_ref(); + return Poll::Pending; + } + } + let remaining = TCP_STREAM_LIMIT.saturating_sub(this.morph.as_ref().unwrap().write_offset); + if remaining == 0 { + return Poll::Ready(Err(exhausted())); + } + let count = usize::try_from(remaining.min(input.len() as u64)) + .expect("count is bounded by usize input length"); + match this.inner.poll_morph_write_ready(cx) { + Poll::Ready(Ok(())) => {} + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Pending => return Poll::Pending, + } + let state = this.morph.as_mut().unwrap(); + if state.write_buffer.len() < count { + state.write_buffer.resize(count, 0); + } + state + .write_cipher + .as_mut() + .expect("Morph write cipher initialized") + .try_apply_keystream_b2b(&input[..count], &mut state.write_buffer[..count]) + .map_err(|_| exhausted())?; + match Pin::new(&mut this.inner).poll_write(cx, &state.write_buffer[..count]) { + Poll::Ready(Ok(0)) => { + state + .write_cipher + .as_mut() + .unwrap() + .try_seek(state.write_offset) + .map_err(|_| exhausted())?; + Poll::Ready(Err(io::ErrorKind::WriteZero.into())) + } + Poll::Ready(Ok(n)) => { + state.write_offset += n as u64; + if n != count { + state + .write_cipher + .as_mut() + .expect("Morph write cipher initialized") + .try_seek(state.write_offset) + .map_err(|_| exhausted())?; + } + Poll::Ready(Ok(n)) + } + Poll::Ready(Err(error)) => { + state + .write_cipher + .as_mut() + .unwrap() + .try_seek(state.write_offset) + .map_err(|_| exhausted())?; + Poll::Ready(Err(error)) + } + Poll::Pending => { + state + .write_cipher + .as_mut() + .unwrap() + .try_seek(state.write_offset) + .map_err(|_| exhausted())?; + Poll::Pending + } + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} diff --git a/src/transport/morph/udp.rs b/src/transport/morph/udp.rs new file mode 100644 index 0000000..2c39fdb --- /dev/null +++ b/src/transport/morph/udp.rs @@ -0,0 +1,309 @@ +// Copyright (C) 2026 NodePassProject +// SPDX-License-Identifier: GPL-3.0-only + +use std::fmt; +use std::io::{self, IoSliceMut}; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; + +use chacha20::ChaCha20; +use chacha20::cipher::{KeyIvInit, StreamCipher}; +use quinn::udp::{RecvMeta, Transmit}; +use quinn::{AsyncUdpSocket, UdpPoller}; + +use super::{MorphKey, NONCE_LEN, exhausted}; + +const QUINN_DEFAULT_MTU_UPPER_BOUND: u16 = 1452; +const MAX_INVALID_RECEIVE_BATCHES: usize = 32; +pub(super) const UDP_NONCE_STREAM_LIMIT: u64 = (1u64 << 38) - 64; + +pub(crate) fn wrap_morph_udp_socket( + inner: Arc, + key: Option, +) -> io::Result> { + match key { + Some(key) => Ok(Arc::new(MorphUdpSocket { + inner, + key, + buffers: Mutex::new(UdpBuffers::new()?), + })), + None => Ok(inner), + } +} + +pub(super) struct UdpBuffers { + send: Vec, + receive: [Vec; quinn::udp::BATCH_SIZE], + pub(super) nonce_generator: UdpNonceGenerator, +} + +impl UdpBuffers { + fn new() -> io::Result { + Ok(Self::from_seed(random_seed()?)) + } + + pub(super) fn from_seed(seed: [u8; 32]) -> Self { + Self { + send: Vec::new(), + receive: std::array::from_fn(|_| Vec::new()), + nonce_generator: UdpNonceGenerator::from_seed(seed), + } + } +} + +pub(super) struct UdpNonceGenerator { + cipher: ChaCha20, + pub(super) generated: u64, +} + +impl UdpNonceGenerator { + pub(super) fn from_seed(seed: [u8; 32]) -> Self { + Self { + cipher: ChaCha20::new((&seed).into(), (&[0u8; NONCE_LEN]).into()), + generated: 0, + } + } + + pub(super) fn generate(&mut self, nonce: &mut [u8; NONCE_LEN]) -> io::Result<()> { + self.generate_with_reseed(nonce, random_seed) + } + + pub(super) fn generate_with_reseed( + &mut self, + nonce: &mut [u8; NONCE_LEN], + reseed: impl FnOnce() -> io::Result<[u8; 32]>, + ) -> io::Result<()> { + if UDP_NONCE_STREAM_LIMIT.saturating_sub(self.generated) < NONCE_LEN as u64 { + let seed = reseed()?; + *self = Self::from_seed(seed); + } + nonce.fill(0); + self.cipher + .try_apply_keystream(nonce) + .map_err(|_| io::Error::other("Morph UDP nonce generator exhausted"))?; + self.generated += NONCE_LEN as u64; + Ok(()) + } +} + +fn random_seed() -> io::Result<[u8; 32]> { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).map_err(io::Error::other)?; + Ok(seed) +} + +pub(crate) fn morph_endpoint_config(enabled: bool) -> anyhow::Result { + let mut config = quinn::EndpointConfig::default(); + if enabled { + let maximum = config + .get_max_udp_payload_size() + .checked_sub(NONCE_LEN as u64) + .expect("Quinn UDP payload limit exceeds Morph overhead"); + config.max_udp_payload_size(maximum as u16)?; + } + Ok(config) +} + +pub(crate) fn configure_morph_mtu(transport: &mut quinn::TransportConfig, enabled: bool) { + if enabled { + let mut discovery = quinn::MtuDiscoveryConfig::default(); + discovery.upper_bound(QUINN_DEFAULT_MTU_UPPER_BOUND - NONCE_LEN as u16); + transport.mtu_discovery_config(Some(discovery)); + } +} + +pub(super) struct MorphUdpSocket { + pub(super) inner: Arc, + pub(super) key: MorphKey, + pub(super) buffers: Mutex, +} + +impl fmt::Debug for MorphUdpSocket { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MorphUdpSocket") + .field("inner", &self.inner) + .finish_non_exhaustive() + } +} + +impl AsyncUdpSocket for MorphUdpSocket { + fn create_io_poller(self: Arc) -> Pin> { + self.inner.clone().create_io_poller() + } + + fn try_send(&self, transmit: &Transmit<'_>) -> io::Result<()> { + let plain_stride = transmit.segment_size.unwrap_or(transmit.contents.len()); + if plain_stride == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "empty UDP datagram", + )); + } + let segments = transmit.contents.len().div_ceil(plain_stride); + let wire_len = transmit + .contents + .len() + .checked_add( + segments + .checked_mul(NONCE_LEN) + .ok_or_else(|| io::Error::other("Morph UDP datagram length overflow"))?, + ) + .ok_or_else(|| io::Error::other("Morph UDP datagram length overflow"))?; + let mut buffers = self.buffers.lock().unwrap_or_else(|lock| lock.into_inner()); + let UdpBuffers { + send, + nonce_generator, + .. + } = &mut *buffers; + if send.len() < wire_len { + send.resize(wire_len, 0); + } + let mut wire_offset = 0; + for plain in transmit.contents.chunks(plain_stride) { + let wire_end = wire_offset + NONCE_LEN + plain.len(); + let (nonce, payload) = send[wire_offset..wire_end].split_at_mut(NONCE_LEN); + let nonce: &mut [u8; NONCE_LEN] = nonce.try_into().expect("fixed nonce prefix"); + nonce_generator.generate(nonce)?; + let mut cipher = ChaCha20::new((&self.key).into(), (&*nonce).into()); + cipher + .try_apply_keystream_b2b(plain, payload) + .map_err(|_| exhausted())?; + wire_offset = wire_end; + } + debug_assert_eq!(wire_offset, wire_len); + let wire_stride = transmit + .segment_size + .map(|size| { + size.checked_add(NONCE_LEN) + .ok_or_else(|| io::Error::other("Morph UDP segment length overflow")) + }) + .transpose()?; + let wire = Transmit { + destination: transmit.destination, + ecn: transmit.ecn, + contents: &send[..wire_len], + segment_size: wire_stride, + src_ip: transmit.src_ip, + }; + self.inner.try_send(&wire) + } + + fn poll_recv( + &self, + cx: &mut Context<'_>, + bufs: &mut [IoSliceMut<'_>], + meta: &mut [RecvMeta], + ) -> Poll> { + let receive_count = bufs.len().min(meta.len()).min(quinn::udp::BATCH_SIZE); + if receive_count == 0 { + return Poll::Ready(Ok(0)); + } + let max_segments = self.inner.max_receive_segments().max(1); + let mut buffers = self.buffers.lock().unwrap_or_else(|lock| lock.into_inner()); + let Some(nonce_overhead) = NONCE_LEN.checked_mul(max_segments) else { + return Poll::Ready(Err(io::Error::other( + "Morph UDP receive buffer length overflow", + ))); + }; + let mut wire_lengths = [0; quinn::udp::BATCH_SIZE]; + for index in 0..receive_count { + let Some(wire_len) = bufs[index].len().checked_add(nonce_overhead) else { + return Poll::Ready(Err(io::Error::other( + "Morph UDP receive buffer length overflow", + ))); + }; + wire_lengths[index] = wire_len; + if buffers.receive[index].len() < wire_len { + buffers.receive[index].resize(wire_len, 0); + } + } + for _ in 0..MAX_INVALID_RECEIVE_BATCHES { + let receive = buffers.receive.each_mut(); + let mut index = 0; + let mut wire_bufs = receive.map(|value| { + let wire_len = wire_lengths[index]; + index += 1; + IoSliceMut::new(&mut value[..wire_len]) + }); + let received = match self.inner.poll_recv( + cx, + &mut wire_bufs[..receive_count], + &mut meta[..receive_count], + ) { + Poll::Ready(Ok(received)) => received.min(receive_count), + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Pending => return Poll::Pending, + }; + let mut output = 0; + for index in 0..received { + let wire_meta = meta[index]; + let wire_stride = wire_meta.stride; + let storage = &buffers.receive[index]; + if wire_meta.len == 0 + || wire_meta.len > wire_lengths[index] + || wire_stride <= NONCE_LEN + || wire_stride > wire_meta.len + { + continue; + } + let segment_count = wire_meta.len.div_ceil(wire_stride); + let Some(nonce_bytes) = segment_count.checked_mul(NONCE_LEN) else { + continue; + }; + let Some(expected_len) = wire_meta.len.checked_sub(nonce_bytes) else { + continue; + }; + let final_wire_len = wire_meta.len % wire_stride; + if expected_len == 0 + || expected_len > bufs[output].len() + || (final_wire_len != 0 && final_wire_len <= NONCE_LEN) + { + continue; + } + let target = &mut bufs[output]; + let decoded_stride = wire_stride - NONCE_LEN; + let mut decoded_len = 0; + for wire in storage[..wire_meta.len].chunks(wire_stride) { + let (nonce, encrypted) = wire.split_at(NONCE_LEN); + let nonce: &[u8; NONCE_LEN] = nonce.try_into().expect("fixed nonce prefix"); + let mut cipher = ChaCha20::new((&self.key).into(), nonce.into()); + let end = decoded_len + encrypted.len(); + cipher + .try_apply_keystream_b2b(encrypted, &mut target[decoded_len..end]) + .map_err(|_| exhausted())?; + decoded_len = end; + } + debug_assert_eq!(decoded_len, expected_len); + meta[output] = RecvMeta { + len: decoded_len, + stride: decoded_stride, + ..wire_meta + }; + output += 1; + } + if output != 0 { + return Poll::Ready(Ok(output)); + } + } + cx.waker().wake_by_ref(); + Poll::Pending + } + + fn local_addr(&self) -> io::Result { + self.inner.local_addr() + } + + fn max_transmit_segments(&self) -> usize { + self.inner.max_transmit_segments() + } + + fn max_receive_segments(&self) -> usize { + self.inner.max_receive_segments() + } + + fn may_fragment(&self) -> bool { + self.inner.may_fragment() + } +} diff --git a/src/transport/owned_io.rs b/src/transport/owned_io.rs new file mode 100644 index 0000000..b3198a7 --- /dev/null +++ b/src/transport/owned_io.rs @@ -0,0 +1,124 @@ +// Copyright (C) 2026 NodePassProject +// SPDX-License-Identifier: GPL-3.0-only + +//! Owned payload handoff for relay paths that terminate in TLS Mux. + +use std::any::Any; +use std::io; +use std::pin::Pin; + +use bytes::Bytes; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader}; + +use crate::mux::{FRAME_BYTES, FlowReader, FlowWriter, MuxChunk}; + +use super::Buffers; + +pub(crate) trait AsyncReadAny: AsyncRead + Send + Unpin { + fn as_any_mut(&mut self) -> &mut dyn Any; +} + +impl AsyncReadAny for T { + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +pub(crate) trait AsyncWriteAny: AsyncWrite + Send + Unpin { + fn as_any_mut(&mut self) -> &mut dyn Any; +} + +impl AsyncWriteAny for T { + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +pub(crate) enum RelayChunk { + Mux(MuxChunk), + Bytes(Bytes), +} + +impl RelayChunk { + pub(crate) fn len(&self) -> usize { + self.as_ref().len() + } +} + +impl AsRef<[u8]> for RelayChunk { + fn as_ref(&self) -> &[u8] { + match self { + Self::Mux(chunk) => chunk.as_ref(), + Self::Bytes(bytes) => bytes, + } + } +} + +pub(crate) async fn read_owned( + reader: &mut Pin>, + buffers: &Buffers, +) -> io::Result> { + let any = reader.as_mut().get_mut().as_any_mut(); + if let Some(reader) = any.downcast_mut::() { + return reader + .recv_chunk() + .await + .map(|chunk| chunk.map(RelayChunk::Mux)); + } + if let Some(reader) = any.downcast_mut::>() + && reader.buffer().is_empty() + { + return reader + .get_mut() + .recv_chunk() + .await + .map(|chunk| chunk.map(RelayChunk::Mux)); + } + read_owned_from(reader, buffers).await +} + +pub(crate) async fn read_owned_from( + reader: &mut R, + buffers: &Buffers, +) -> io::Result> { + let mut payload = buffers.get_tcp_buffer(); + let capacity = payload.len().min(FRAME_BYTES); + let count = reader.read(&mut payload[..capacity]).await?; + if count == 0 { + Ok(None) + } else { + Ok(Some(RelayChunk::Bytes( + Bytes::from_owner(payload).slice(..count), + ))) + } +} + +pub(crate) async fn write_owned( + writer: &mut Pin>, + chunk: RelayChunk, +) -> io::Result<()> { + if let Some(writer) = writer + .as_mut() + .get_mut() + .as_any_mut() + .downcast_mut::() + { + let chunk = match chunk { + RelayChunk::Mux(chunk) => chunk, + RelayChunk::Bytes(bytes) => MuxChunk::from_bytes(bytes), + }; + return writer.send_chunk(chunk).await; + } + writer.write_all(chunk.as_ref()).await +} + +pub(crate) async fn write_owned_to( + writer: &mut W, + chunk: RelayChunk, +) -> io::Result<()> { + writer.write_all(chunk.as_ref()).await +} + +#[cfg(test)] +#[path = "../tests/transport/owned_io.rs"] +mod tests; diff --git a/src/transport/quic.rs b/src/transport/quic.rs index 8b2fb95..b6675a8 100644 --- a/src/transport/quic.rs +++ b/src/transport/quic.rs @@ -1,20 +1,20 @@ // Copyright (C) 2026 NodePassProject // SPDX-License-Identifier: GPL-3.0-only -//! Shared QUIC flow-control budgets for every ALPN and Mux setting. +//! Shared transport flow-control budgets for QUIC and TLS Mux. use anyhow::{Result, bail}; const MIB: u32 = 1024 * 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) struct QuicFlowControl { +pub(crate) struct TransportFlowControl { pub(crate) stream_receive_window: u32, pub(crate) connection_receive_window: u32, pub(crate) send_window: u64, } -impl QuicFlowControl { +impl TransportFlowControl { const MEMORY: Self = Self::new(4, 8, 8); const BALANCED: Self = Self::new(8, 16, 16); const THROUGHPUT: Self = Self::new(16, 32, 32); @@ -32,16 +32,20 @@ impl QuicFlowControl { /// /// The throughput profile preserves the established high-BDP values; memory /// and balanced remain available through the environment override. -pub(crate) fn quic_flow_control() -> Result { - parse_quic_profile(std::env::var("NOW_QUIC_MEMORY_PROFILE").ok().as_deref()) +pub(crate) fn transport_flow_control() -> Result { + parse_transport_profile( + std::env::var("NOW_TRANSPORT_MEMORY_PROFILE") + .ok() + .as_deref(), + ) } -fn parse_quic_profile(value: Option<&str>) -> Result { +fn parse_transport_profile(value: Option<&str>) -> Result { match value.unwrap_or("throughput") { - "memory" => Ok(QuicFlowControl::MEMORY), - "balanced" => Ok(QuicFlowControl::BALANCED), - "throughput" => Ok(QuicFlowControl::THROUGHPUT), - _ => bail!("NOW_QUIC_MEMORY_PROFILE must be memory, balanced, or throughput"), + "memory" => Ok(TransportFlowControl::MEMORY), + "balanced" => Ok(TransportFlowControl::BALANCED), + "throughput" => Ok(TransportFlowControl::THROUGHPUT), + _ => bail!("NOW_TRANSPORT_MEMORY_PROFILE must be memory, balanced, or throughput"), } } diff --git a/src/tui/client/adapter.rs b/src/tui/client/adapter.rs index 4d419ce..21f8c17 100644 --- a/src/tui/client/adapter.rs +++ b/src/tui/client/adapter.rs @@ -20,7 +20,7 @@ pub(super) fn hello_ui_event(hello: &Hello) -> UiEvent { endpoint: descriptor.endpoint.clone(), config_summary: descriptor.config_summary.clone(), telemetry_interval_ms: descriptor.telemetry_interval_ms, - telemetry_protocol_version: descriptor.protocol_version, + telemetry_version: descriptor.telemetry_version, }, lifecycle: Lifecycle::from_label(&hello.lifecycle), snapshot: None, @@ -138,7 +138,6 @@ pub(super) fn access_start_ui_value(value: AccessStarted) -> AccessRecord { TrafficProtocol::Udp => "UDP", } .to_owned(), - alpn: value.alpn, session_tag: value.session_tag, client: value.client, path_peers: value.path_peers, @@ -161,7 +160,6 @@ pub(super) fn access_finish_ui_value( client, path_peers, target, - alpn, session_tag, initial_uplink, initial_downlink, @@ -176,7 +174,6 @@ pub(super) fn access_finish_ui_value( id, timestamp_ms, protocol, - alpn, flow_id, session_tag, client, diff --git a/src/tui/model/types.rs b/src/tui/model/types.rs index e566187..7e6066c 100644 --- a/src/tui/model/types.rs +++ b/src/tui/model/types.rs @@ -85,7 +85,7 @@ pub struct InstanceMeta { pub endpoint: String, pub config_summary: String, pub telemetry_interval_ms: u64, - pub telemetry_protocol_version: u16, + pub telemetry_version: u16, } /// Runtime event severity. @@ -133,7 +133,6 @@ pub struct AccessRecord { pub event_id: u64, pub phase: AccessPhase, pub protocol: String, - pub alpn: String, pub session_tag: Option, pub client: Option, pub path_peers: Vec, diff --git a/src/tui/render/feed.rs b/src/tui/render/feed.rs index 5018301..f3d22f6 100644 --- a/src/tui/render/feed.rs +++ b/src/tui/render/feed.rs @@ -127,7 +127,6 @@ fn access_line(record: &AccessRecord, app: &App) -> Line<'static> { format!("{:<4}", record.protocol.to_ascii_uppercase()), accent(app, palette::protocol(&record.protocol)), ), - Span::raw(" "), Span::styled(format!("{status:<4}"), accent(app, status_color)), Span::raw(format!( " {} {} {}", diff --git a/src/vector/config.rs b/src/vector/config.rs index cbcbb8b..8b3ab33 100644 --- a/src/vector/config.rs +++ b/src/vector/config.rs @@ -12,10 +12,13 @@ use url::Url; use crate::common::socks::{ SocksCredentials, first_raw_socks_value, format_host_port, parse_host_port, parse_socks_value, }; -use crate::common::{DEFAULT_DIALER_IP, parse_alpn, query_first}; +use crate::common::{ + CarrierEndpoint, DEFAULT_DIALER_IP, ServiceEndpoint, query_first, validate_endpoint_url_input, +}; +use crate::transport::MorphKeys; const VECTOR_QUERY_KEYS: &[&str] = &[ - "up", "down", "alpn", "mux", "sni", "pin", "rate", "etar", "socks", "log", + "up", "down", "mux", "sni", "pin", "rate", "etar", "morph", "socks", "log", ]; /// Whether a client originates dedicated or Mux TLS carriers. @@ -54,13 +57,18 @@ pub(crate) enum CarrierMode { } impl CarrierMode { - pub(crate) fn parse(value: Option<&str>, name: &str) -> Result { + pub(crate) fn parse( + value: Option<&str>, + name: &str, + default: Self, + context: &str, + ) -> Result { match value { - None => Ok(Self::Udp), + None => Ok(default), Some("tcp") => Ok(Self::Tcp), Some("udp") => Ok(Self::Udp), Some("mix") => Ok(Self::Mix), - Some(_) => bail!("vector::config: {name} must be tcp, udp, or mix"), + Some(_) => bail!("{context}: {name} must be tcp, udp, or mix"), } } @@ -72,41 +80,52 @@ impl CarrierMode { /// Transport-only configuration shared by Vector and Portal upstream clients. #[derive(Clone, Debug)] pub(crate) struct PortalClientConfig { - pub(crate) remote_host: String, - pub(crate) remote_port: u16, + pub(crate) remote: ServiceEndpoint, pub(crate) up: CarrierMode, pub(crate) down: CarrierMode, - pub(crate) alpn: String, pub(crate) mux: MuxMode, + pub(crate) morph: bool, + pub(crate) morph_keys: Option, pub(crate) sni: Option, pub(crate) pin: Option, pub(crate) dialer_ip: String, } impl PortalClientConfig { - fn parse(url: &Url, query: &HashMap, dialer_ip: &str) -> Result { - let remote_host = url - .host_str() - .filter(|host| !host.is_empty()) - .ok_or_else(|| anyhow!("vector::config: missing Portal host"))? - .trim_start_matches('[') - .trim_end_matches(']') - .to_owned(); - let remote_port = url - .port() - .filter(|port| *port != 0) - .ok_or_else(|| anyhow!("vector::config: missing Portal port"))?; - let up = CarrierMode::parse(query.get("up").map(String::as_str), "up")?; - let down = CarrierMode::parse(query.get("down").map(String::as_str), "down")?; - let alpn = parse_alpn(query.get("alpn").map(String::as_str)) - .map_err(|error| anyhow!("vector::config: {error}"))?; + fn parse( + url: &Url, + query: &HashMap, + dialer_ip: &str, + context: &str, + ) -> Result { + let remote = ServiceEndpoint::parse(url, false, context)?; + let default = match (remote.has_tcp(), remote.has_udp()) { + (true, false) | (true, true) => CarrierMode::Tcp, + (false, true) => CarrierMode::Udp, + (false, false) => unreachable!(), + }; + let up = CarrierMode::parse(query.get("up").map(String::as_str), "up", default, context)?; + let down = CarrierMode::parse( + query.get("down").map(String::as_str), + "down", + default, + context, + )?; + validate_carrier_policy(&remote, up, "up", context)?; + validate_carrier_policy(&remote, down, "down", context)?; let mux = MuxMode::parse(query.get("mux").map(String::as_str)) - .map_err(|error| anyhow!("vector::config: {error}"))?; + .map_err(|error| anyhow!("{context}: {error}"))?; let mux = if up == CarrierMode::Udp && down == CarrierMode::Udp { MuxMode::Disabled } else { mux }; + let morph = match query.get("morph").map(String::as_str) { + None | Some("0") => false, + Some("1") => true, + Some(_) => bail!("{context}: morph must be 0 or 1"), + }; + let morph_keys = morph.then(|| MorphKeys::from_url(url)).transpose()?; let sni = query .get("sni") .filter(|value| !value.is_empty() && value.as_str() != "none") @@ -116,7 +135,7 @@ impl PortalClientConfig { || value.contains([':', '[', ']']) || value.parse::().is_ok() { - bail!("vector::config: sni must be an ASCII DNS name"); + bail!("{context}: sni must be an ASCII DNS name"); } Ok(value.to_owned()) }) @@ -126,12 +145,12 @@ impl PortalClientConfig { .filter(|value| !value.is_empty() && value.as_str() != "none") .cloned(); Ok(Self { - remote_host, - remote_port, + remote, up, down, - alpn, mux, + morph, + morph_keys, sni, pin, dialer_ip: dialer_ip.to_owned(), @@ -143,40 +162,50 @@ impl PortalClientConfig { query: &HashMap, dialer_ip: &str, ) -> Result<(Self, crate::protocol::Credentials)> { + validate_endpoint_url_input(&format!("vector://{raw_authority}"), "Portal next endpoint")?; let separator = raw_authority.rfind('@').ok_or_else(|| { - anyhow!("portal::next: shared key and endpoint must be separated by @") + anyhow!("Portal next endpoint: shared key and endpoint must be separated by '@'") })?; if raw_authority[..separator].contains('@') { - bail!("portal::next: reserved shared-key characters must be percent-encoded"); + bail!("Portal next endpoint: reserved shared-key characters must be percent-encoded"); } let url = Url::parse(&format!("vector://{raw_authority}")) - .map_err(|error| anyhow!("portal::next: invalid upstream Portal authority: {error}"))?; - if url.password().is_some() - || !url.path().is_empty() - || url.query().is_some() - || url.fragment().is_some() - { - bail!("portal::next: expected only shared-key@host:port"); + .map_err(|error| anyhow!("Portal next endpoint: invalid authority: {error}"))?; + if url.password().is_some() || url.query().is_some() || url.fragment().is_some() { + bail!( + "Portal next endpoint: expected shared-key and one endpoint without a query or fragment" + ); } - let credentials = crate::protocol::Credentials::new(&url) - .map_err(|error| anyhow!("portal::next: {error}"))?; - let config = Self::parse(&url, query, dialer_ip) - .map_err(|error| anyhow!("portal::next: {error}"))?; + let credentials = crate::protocol::Credentials::new(&url)?; + let config = Self::parse(&url, query, dialer_ip, "Portal next endpoint")?; Ok((config, credentials)) } pub(crate) fn endpoint(&self) -> String { - format_host_port(&self.remote_host, self.remote_port) + self.remote.canonical() + } + + pub(crate) fn host(&self) -> &str { + &self.remote.host + } + + pub(crate) fn tcp_endpoint(&self) -> Option { + self.remote.tcp + } + + pub(crate) fn udp_endpoint(&self) -> Option { + self.remote.udp } pub(crate) fn effective_route(&self) -> String { format!( - "up={} down={} mux={} sni={} pin={}", + "up={} down={} mux={} sni={} pin={} morph={}", self.up, self.down, self.mux, self.sni.as_deref().unwrap_or("none"), self.pin.as_deref().unwrap_or("none"), + u8::from(self.morph), ) } } @@ -202,9 +231,9 @@ pub(crate) struct SocksListenConfig { impl SocksListenConfig { fn from_url(url: &Url) -> Result { let raw_value = first_raw_socks_value(url) - .ok_or_else(|| anyhow!("vector::config: socks parameter is required"))?; + .ok_or_else(|| anyhow!("Vector configuration: socks parameter is required"))?; if raw_value.is_empty() { - bail!("vector::config: socks must not be empty"); + bail!("Vector configuration: socks must not be empty"); } let (endpoint, credentials) = parse_socks_value(raw_value)?; let (host, port) = parse_host_port(&endpoint, "socks listener", true)?; @@ -227,12 +256,12 @@ impl SocksListenConfig { /// Fully validated Vector runtime configuration. #[derive(Clone, Debug)] pub(crate) struct VectorConfig { - pub(super) remote_host: String, - pub(super) remote_port: u16, + pub(super) remote: ServiceEndpoint, pub(super) up: CarrierMode, pub(super) down: CarrierMode, - pub(super) alpn: String, pub(super) mux: MuxMode, + pub(super) morph: bool, + pub(super) morph_keys: Option, pub(super) sni: Option, pub(super) pin: Option, pub(super) rate_mbps: i32, @@ -243,34 +272,30 @@ pub(crate) struct VectorConfig { impl VectorConfig { pub(super) fn from_url(url: &Url) -> Result { if url.scheme() != "vector" { - bail!("vector::config: URL scheme must be vector"); + bail!("Vector configuration: URL scheme must be vector"); } if url.password().is_some() { - bail!("vector::config: URL password component is not supported"); + bail!("Vector configuration: URL password component is not supported"); } if url.username().is_empty() { - bail!("vector::config: missing shared key"); + bail!("Vector configuration: missing shared key before '@'"); } if url.fragment().is_some() { - bail!("vector::config: URL fragment is not supported"); - } - if !url.path().is_empty() { - bail!("vector::config: URL path is not supported"); + bail!("Vector configuration: URL fragment is not supported"); } - let query = query_first(url, VECTOR_QUERY_KEYS)?; - let portal = PortalClientConfig::parse(url, &query, DEFAULT_DIALER_IP)?; + let portal = PortalClientConfig::parse(url, &query, DEFAULT_DIALER_IP, "Vector endpoint")?; let rate_mbps = parse_rate(query.get("rate").map(String::as_str), "rate")?; let etar_mbps = parse_rate(query.get("etar").map(String::as_str), "etar")?; let socks = SocksListenConfig::from_url(url)?; Ok(Self { - remote_host: portal.remote_host, - remote_port: portal.remote_port, + remote: portal.remote, up: portal.up, down: portal.down, - alpn: portal.alpn, mux: portal.mux, + morph: portal.morph, + morph_keys: portal.morph_keys, sni: portal.sni, pin: portal.pin, rate_mbps, @@ -281,12 +306,12 @@ impl VectorConfig { pub(crate) fn portal_client_config(&self) -> PortalClientConfig { PortalClientConfig { - remote_host: self.remote_host.clone(), - remote_port: self.remote_port, + remote: self.remote.clone(), up: self.up, down: self.down, - alpn: self.alpn.clone(), mux: self.mux, + morph: self.morph, + morph_keys: self.morph_keys.clone(), sni: self.sni.clone(), pin: self.pin.clone(), dialer_ip: DEFAULT_DIALER_IP.to_owned(), @@ -294,7 +319,7 @@ impl VectorConfig { } pub(super) fn portal_endpoint(&self) -> String { - format_host_port(&self.remote_host, self.remote_port) + self.remote.canonical() } pub(super) fn checkpoint_mode(&self) -> u8 { @@ -313,21 +338,38 @@ impl VectorConfig { pub(super) fn effective_url(&self) -> String { format!( - "vector://{}?up={}&down={}&alpn={}&mux={}&sni={}&pin={}&rate={}&etar={}&socks={}", + "vector://{}?up={}&down={}&mux={}&sni={}&pin={}&rate={}&etar={}&morph={}&socks={}", self.portal_endpoint(), self.up, self.down, - self.alpn, self.mux, self.sni.as_deref().unwrap_or("none"), self.pin.as_deref().unwrap_or("none"), self.rate_mbps, self.etar_mbps, + u8::from(self.morph), self.socks.endpoint(), ) } } +fn validate_carrier_policy( + endpoint: &ServiceEndpoint, + mode: CarrierMode, + name: &str, + context: &str, +) -> Result<()> { + let available = match mode { + CarrierMode::Tcp => endpoint.has_tcp(), + CarrierMode::Udp => endpoint.has_udp(), + CarrierMode::Mix => endpoint.has_tcp() && endpoint.has_udp(), + }; + if !available { + bail!("{context}: {name} selects a carrier not declared by the endpoint"); + } + Ok(()) +} + fn parse_rate(value: Option<&str>, name: &str) -> Result { match value { None => Ok(0), @@ -335,7 +377,7 @@ fn parse_rate(value: Option<&str>, name: &str) -> Result { .parse::() .ok() .filter(|value| *value >= 0) - .ok_or_else(|| anyhow!("vector::config: {name} must be a non-negative integer")), + .ok_or_else(|| anyhow!("Vector configuration: {name} must be a non-negative integer")), } } diff --git a/src/vector/flow.rs b/src/vector/flow.rs index 922a02d..47cc9cc 100644 --- a/src/vector/flow.rs +++ b/src/vector/flow.rs @@ -11,9 +11,8 @@ use std::task::{Context as TaskContext, Poll}; use std::time::Duration; use anyhow::{Context, Result, anyhow}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tokio::net::TcpStream; -use tokio::sync::OwnedSemaphorePermit; use tokio::time::timeout; use crate::common::socks::{ @@ -33,15 +32,15 @@ use crate::telemetry::{AccessOutcome, AccessSpan, RuntimeEvent, RuntimeKind, Run use super::config::CarrierMode; use super::flow_id::FlowLease; use super::route::{ResolvedRoute, RoutePlan}; -use super::session::{LinkGuard, MuxDirection, OpenedTls, QuicSession}; +use super::session::{LinkGuard, OpenedTls, QuicSession}; use super::{PortalClient, VectorInner}; mod tcp; pub(crate) use self::tcp::{TcpTunnel, TcpTunnelGuard}; pub(super) use self::tcp::{open_tcp, relay_tcp}; -pub(crate) type BoxReader = Pin>; -pub(crate) type BoxWriter = Pin>; +pub(crate) type BoxReader = Pin>; +pub(crate) type BoxWriter = Pin>; pub(super) struct PhysicalLane { pub(super) reader: Option, @@ -87,22 +86,17 @@ pub(super) async fn open_lane( client: Arc, carrier: Carrier, flow_id: u32, - direction: MuxDirection, ) -> Result { match carrier { Carrier::TlsTcp => { - let opened = client - .tls_manager - .open(flow_id, direction) - .await - .map_err(|error| { - client.telemetry.emit_runtime(RuntimeEvent::new( - RuntimeLevel::Warn, - RuntimeKind::Carrier, - format!("TLS carrier connection failed: {error}"), - )); - error - })?; + let opened = client.tls_manager.open(flow_id).await.map_err(|error| { + client.telemetry.emit_runtime(RuntimeEvent::new( + RuntimeLevel::Warn, + RuntimeKind::Carrier, + format!("TLS carrier connection failed: {error}"), + )); + error + })?; match opened { OpenedTls::Mux(stream) => { let (reader, writer) = stream.into_split(); @@ -173,14 +167,12 @@ pub(super) async fn prepare_lanes( flow_id: u32, ) -> Result> { if !route.split() { - return Ok(vec![ - open_lane(client, route.uplink, flow_id, MuxDirection::Up).await?, - ]); + return Ok(vec![open_lane(client, route.uplink, flow_id).await?]); } let (uplink, downlink) = tokio::join!( - open_lane(client.clone(), route.uplink, flow_id, MuxDirection::Up,), - open_lane(client, route.downlink, flow_id, MuxDirection::Down), + open_lane(client.clone(), route.uplink, flow_id), + open_lane(client, route.downlink, flow_id), ); match (uplink, downlink) { (Ok(uplink), Ok(downlink)) => Ok(vec![uplink, downlink]), @@ -279,8 +271,7 @@ pub(super) async fn write_open_request( header: FlowHeader, target: &Target, ) -> Result<()> { - header.validate()?; - let flow = write_flow_header(header); + let flow = write_flow_header(header)?; let mut request = [0u8; AUTH_FRAME_LEN + FLOW_HEADER_LEN + TARGET_MAX_ENCODED_LEN]; let auth_len = if let Some(auth) = pending_auth { request[..AUTH_FRAME_LEN].copy_from_slice(&auth); @@ -308,8 +299,7 @@ pub(super) async fn write_header( pending_auth: Option, header: FlowHeader, ) -> Result<()> { - header.validate()?; - let flow = write_flow_header(header); + let flow = write_flow_header(header)?; let mut request = [0u8; AUTH_FRAME_LEN + FLOW_HEADER_LEN]; let auth_len = if let Some(auth) = pending_auth { request[..AUTH_FRAME_LEN].copy_from_slice(&auth); diff --git a/src/vector/flow/tcp.rs b/src/vector/flow/tcp.rs index 29b4d54..99f6413 100644 --- a/src/vector/flow/tcp.rs +++ b/src/vector/flow/tcp.rs @@ -4,6 +4,7 @@ //! TCP tunnel setup and bidirectional relay. use super::*; +use crate::transport::{read_owned, read_owned_from, write_owned, write_owned_to}; pub(crate) struct TcpTunnel { reader: BoxReader, @@ -12,13 +13,11 @@ pub(crate) struct TcpTunnel { _lease: Option, uplink: Carrier, downlink: Carrier, - _flow_permit: Option, } pub(crate) struct TcpTunnelGuard { _lanes: Vec, _lease: Option, - _flow_permit: Option, } impl TcpTunnel { @@ -38,17 +37,8 @@ impl TcpTunnel { _lease, uplink: _, downlink: _, - _flow_permit, } = self; - ( - reader, - writer, - TcpTunnelGuard { - _lanes, - _lease, - _flow_permit, - }, - ) + (reader, writer, TcpTunnelGuard { _lanes, _lease }) } } @@ -88,11 +78,6 @@ pub(crate) async fn open_tcp( target: &Target, hops: u8, ) -> std::result::Result { - let flow_permit = client - .tcp_flow_permits - .clone() - .try_acquire_owned() - .map_err(|_| OpenFlowError::Setup(SetupResult::FlowLimit))?; let lease = client .flow_ids .allocate() @@ -145,7 +130,6 @@ pub(crate) async fn open_tcp( _lease: Some(lease), uplink, downlink, - _flow_permit: Some(flow_permit), }); } @@ -195,7 +179,6 @@ pub(crate) async fn open_tcp( _lease: Some(lease), uplink, downlink, - _flow_permit: Some(flow_permit), }) } @@ -221,21 +204,19 @@ pub(in crate::vector) async fn relay_tcp( let result = { let (mut client_read, mut client_write) = client.into_split(); - let mut up_buffer = vector.buffers.get_tcp_buffer(); - let mut down_buffer = vector.buffers.get_tcp_buffer(); let uplink = tunnel.uplink; let downlink = tunnel.downlink; let client_to_portal = async { loop { - let read = client_read.read(&mut up_buffer).await?; - if read == 0 { + let Some(chunk) = read_owned_from(&mut client_read, &vector.buffers).await? else { tunnel.writer.shutdown().await?; return Ok::<(), anyhow::Error>(()); - } + }; + let read = chunk.len(); if let Some(rate) = &vector.rate_limiter { rate.wait_read(read as i64).await; } - tunnel.writer.write_all(&up_buffer[..read]).await?; + write_owned(&mut tunnel.writer, chunk).await?; if uplink == Carrier::Quic && downlink == Carrier::TlsTcp { // A continuously writable QUIC stream can otherwise keep // this relay hot long enough to delay the opposite Mux @@ -252,15 +233,15 @@ pub(in crate::vector) async fn relay_tcp( }; let portal_to_client = async { loop { - let read = tunnel.reader.read(&mut down_buffer).await?; - if read == 0 { + let Some(chunk) = read_owned(&mut tunnel.reader, &vector.buffers).await? else { client_write.shutdown().await?; return Ok::<(), anyhow::Error>(()); - } + }; + let read = chunk.len(); if let Some(rate) = &vector.rate_limiter { rate.wait_write(read as i64).await; } - client_write.write_all(&down_buffer[..read]).await?; + write_owned_to(&mut client_write, chunk).await?; access.add_download(read as u64); vector .stats diff --git a/src/vector/flow_id.rs b/src/vector/flow_id.rs index 9bec8fb..cdbfb74 100644 --- a/src/vector/flow_id.rs +++ b/src/vector/flow_id.rs @@ -1,7 +1,7 @@ // Copyright (C) 2026 NodePassProject // SPDX-License-Identifier: GPL-3.0-only -//! Collision-free `u32` flow identifier allocation. +//! Collision-free allocation within the shared 30-bit flow identifier space. use std::collections::HashSet; use std::sync::atomic::{AtomicU32, Ordering}; @@ -9,36 +9,38 @@ use std::sync::{Arc, Mutex}; use anyhow::{Result, bail}; -use crate::protocol::FlowId; +use crate::protocol::{FlowId, MAX_FLOW_ID}; #[derive(Debug)] pub(super) struct FlowIdAllocator { next: AtomicU32, active: Mutex>, - limit: usize, } impl FlowIdAllocator { - pub(super) fn new(limit: usize) -> Arc { + pub(super) fn new() -> Arc { Arc::new(Self { next: AtomicU32::new(1), - active: Mutex::new(HashSet::with_capacity(limit.min(4_096))), - limit, + active: Mutex::new(HashSet::new()), }) } pub(super) fn allocate(self: &Arc) -> Result { + self.allocate_with_limit(MAX_FLOW_ID) + } + + fn allocate_with_limit(self: &Arc, max_id: FlowId) -> Result { let mut active = self.active.lock().unwrap_or_else(|lock| lock.into_inner()); - if active.len() >= self.limit { - bail!("vector::flow_id: active flow limit reached"); + if active.len() == max_id as usize { + bail!("vector::flow_id: flow identifier space exhausted"); } - for _ in 0..=self.limit { - let id = self.next.fetch_add(1, Ordering::Relaxed); - let id = if id == 0 { - self.next.fetch_add(1, Ordering::Relaxed) - } else { - id - }; + for _ in 0..=active.len() { + let id = self + .next + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| { + Some(if id >= max_id { 1 } else { id + 1 }) + }) + .unwrap_or_else(|id| id); if id != 0 && active.insert(id) { return Ok(FlowLease { id, diff --git a/src/vector/mod.rs b/src/vector/mod.rs index a8772ef..1d72d00 100644 --- a/src/vector/mod.rs +++ b/src/vector/mod.rs @@ -29,14 +29,17 @@ use self::session::{ClientSignals, QuicManager, TlsManager}; use self::tls::ClientTls; use crate::common::{ LatencyTracker, LifeMode, LifeReason, LifeState, Lifecycle, Logger, ShutdownSignals, - max_tcp_flows, max_udp_flows, rate_limit_bytes_per_second, shutdown_timeout, tcp_data_buf_size, - telemetry_interval, udp_data_buf_size, + rate_limit_bytes_per_second, shutdown_timeout, tcp_data_buf_size, telemetry_interval, + udp_data_buf_size, }; use crate::protocol::{Credentials, SESSION_ID_LEN}; use crate::telemetry::TelemetryServer; use crate::telemetry::{InstanceRole, TelemetryHub}; use crate::transport::{Buffers, RateLimiter, Stats}; +const SOCKS_CLIENT_RESOURCE_LIMIT: usize = 1024; +const SOCKS_UDP_TARGET_RESOURCE_LIMIT: usize = 1024; + /// Runnable native client serving a local SOCKS5 endpoint. pub struct Vector { inner: Arc, @@ -53,7 +56,8 @@ pub(super) struct VectorInner { rate_limiter: Option>, client: Arc, local_udp_budget: Arc, - socks_admission: Arc, + socks_client_admission: Arc, + socks_udp_target_admission: Arc, shutdown: CancellationToken, } @@ -66,8 +70,6 @@ pub(crate) struct PortalClient { account_stats: bool, latency: Arc, flow_ids: Arc, - tcp_flow_permits: Arc, - udp_flow_permits: Arc, tls_manager: Arc, quic: Arc, route_seed: u64, @@ -129,17 +131,13 @@ impl PortalClient { signals, shutdown.clone(), ); - let tcp_limit = max_tcp_flows().max(1) as usize; - let udp_limit = max_udp_flows(); Ok(Arc::new(Self { config, telemetry, stats, account_stats, latency, - flow_ids: FlowIdAllocator::new(tcp_limit.saturating_add(udp_limit)), - tcp_flow_permits: Arc::new(Semaphore::new(tcp_limit)), - udp_flow_permits: Arc::new(Semaphore::new(udp_limit)), + flow_ids: FlowIdAllocator::new(), tls_manager, quic, route_seed, @@ -203,19 +201,17 @@ impl Vector { } fn build(parsed_url: Url, logger: Logger, lifecycle: Arc) -> Result { - let config = VectorConfig::from_url(&parsed_url) - .context("vector::Vector::new: invalid Vector configuration")?; + let config = VectorConfig::from_url(&parsed_url)?; let telemetry_interval = telemetry_interval().context("vector::Vector::new: invalid NOW_TELEMETRY_INTERVAL")?; - let credentials = - Credentials::new(&parsed_url).context("vector::Vector::new: invalid shared key")?; + let credentials = Credentials::new(&parsed_url)?; let telemetry_summary = format!( - "portal={} up={} down={} alpn={} mux={} socks={}", + "portal={} up={} down={} mux={} morph={} socks={}", config.portal_endpoint(), config.up, config.down, - config.alpn, config.mux, + u8::from(config.morph), config.socks.endpoint(), ); let telemetry = TelemetryHub::for_current_process( @@ -226,8 +222,6 @@ impl Vector { ); let stats = Arc::new(Stats::default()); let shutdown = CancellationToken::new(); - let tcp_limit = max_tcp_flows().max(1) as usize; - let udp_limit = max_udp_flows(); let read_bps = rate_limit_bytes_per_second(config.rate_mbps) as i64; let write_bps = rate_limit_bytes_per_second(config.etar_mbps) as i64; let rate_limiter = RateLimiter::new(read_bps, write_bps).map(Arc::new); @@ -253,7 +247,10 @@ impl Vector { rate_limiter, client, local_udp_budget: Arc::new(Semaphore::new(udp_queue_bytes)), - socks_admission: Arc::new(Semaphore::new(tcp_limit.saturating_add(udp_limit))), + socks_client_admission: Arc::new(Semaphore::new(SOCKS_CLIENT_RESOURCE_LIMIT)), + socks_udp_target_admission: Arc::new(Semaphore::new( + SOCKS_UDP_TARGET_RESOURCE_LIMIT, + )), shutdown, }), }) diff --git a/src/vector/session.rs b/src/vector/session.rs index 132207f..f4523fb 100644 --- a/src/vector/session.rs +++ b/src/vector/session.rs @@ -21,9 +21,8 @@ use tokio_rustls::client::TlsStream; use tokio_util::sync::CancellationToken; use crate::common::{ - BudgetedDatagram, LatencyGuard, LatencyTracker, UdpDatagramSend, filter_addrs, - handshake_timeout, parse_local_ip, reserve_udp_budget, send_quic_udp_packet, service_cooldown, - udp_idle_timeout, + BudgetedDatagram, LatencyGuard, LatencyTracker, UdpDatagramSend, handshake_timeout, + parse_local_ip, reserve_udp_budget, send_quic_udp_packet, service_cooldown, udp_idle_timeout, }; use crate::mux::{MUX_IDLE_TIMEOUT, MuxConfig, MuxHandle, MuxStream}; use crate::protocol::{ @@ -32,13 +31,14 @@ use crate::protocol::{ encode_auth_frame, encode_udp_close, }; use crate::telemetry::{RuntimeEvent, RuntimeKind, RuntimeLevel, TelemetryHub}; -use crate::transport::{Stats, quic_flow_control}; +use crate::transport::MorphTcpStream; +use crate::transport::{Stats, transport_flow_control}; use super::config::PortalClientConfig; -use super::tls::{ClientTls, EXPORTER_LABEL}; +use super::tls::{ClientTls, EXPORTER_LABEL, require_quic_nw2}; const QUIC_DATAGRAM_BUFFER_SIZE: usize = 4 * 1024 * 1024; -const TLS_MUX_FLOWS_PER_SHARD: usize = 4; +const TLS_MUX_MAX_CARRIERS: usize = 8; #[derive(Clone)] pub(super) struct ClientSignals { @@ -62,15 +62,15 @@ impl ClientSignals { } pub(super) struct TlsLane { - pub(super) stream: TlsStream, + pub(super) stream: TlsStream>, pending_auth: Option, _link: LinkGuard, latency: LatencyGuard, } pub(super) struct TlsLaneParts { - pub(super) reader: tokio::io::ReadHalf>, - pub(super) writer: tokio::io::WriteHalf>, + pub(super) reader: tokio::io::ReadHalf>>, + pub(super) writer: tokio::io::WriteHalf>>, pub(super) pending_auth: Option, pub(super) link: LinkGuard, pub(super) latency: LatencyGuard, @@ -148,7 +148,7 @@ mod quic; mod tls; pub(super) use self::quic::{QueuedDatagram, QuicManager, QuicSession}; -pub(super) use self::tls::{MuxDirection, OpenedTls, TlsManager}; +pub(super) use self::tls::{OpenedTls, TlsManager}; #[cfg(test)] #[path = "../tests/vector/session.rs"] diff --git a/src/vector/session/quic.rs b/src/vector/session/quic.rs index 4ea8607..d96a6cd 100644 --- a/src/vector/session/quic.rs +++ b/src/vector/session/quic.rs @@ -85,14 +85,21 @@ impl QuicManager { } async fn connect(&self) -> Result> { + let endpoint = self.config.udp_endpoint().ok_or_else(|| { + anyhow!("vector::session::QuicManager::connect: UDP carrier is not configured") + })?; let resolved = timeout( handshake_timeout(), - lookup_host((self.config.remote_host.as_str(), self.config.remote_port)), + lookup_host((self.config.host(), endpoint.port)), ) .await .map_err(|_| anyhow!("vector::session::QuicManager::connect: Portal DNS timeout"))? .context("vector::session::QuicManager::connect: Portal DNS failed")?; - let addresses = filter_addrs(resolved, parse_local_ip(&self.config.dialer_ip)); + let addresses = crate::common::filter_addrs_for_family( + resolved, + parse_local_ip(&self.config.dialer_ip), + endpoint.family, + ); if addresses.is_empty() { bail!("vector::session::QuicManager::connect: no Portal address resolved"); } @@ -114,13 +121,34 @@ impl QuicManager { } None => SocketAddr::new(IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), 0), }; - let mut endpoint = Endpoint::client(bind) + let socket = std::net::UdpSocket::bind(bind) .with_context(|| format!("vector::session::QuicManager: bind {bind} failed"))?; + socket + .set_nonblocking(true) + .context("vector::session::QuicManager: failed to configure UDP socket")?; + let runtime = quinn::default_runtime() + .ok_or_else(|| anyhow!("vector::session::QuicManager: no async runtime found"))?; + let socket = runtime + .wrap_udp_socket(socket) + .context("vector::session::QuicManager: failed to initialize UDP runtime socket")?; + let morph_enabled = self.config.morph_keys.is_some(); + let socket = crate::transport::wrap_morph_udp_socket( + socket, + self.config.morph_keys.as_ref().map(|keys| keys.udp_key()), + )?; + let mut endpoint = Endpoint::new_with_abstract_socket( + crate::transport::morph_endpoint_config(morph_enabled)?, + None, + socket, + runtime, + ) + .context("vector::session::QuicManager: failed to create QUIC endpoint")?; let mut client_config = self.tls.quic_client_config()?; configure_quic_transport( &mut client_config, udp_idle_timeout(), Duration::from_secs(15), + morph_enabled, )?; endpoint.set_default_client_config(client_config); let connecting = endpoint @@ -130,6 +158,7 @@ impl QuicManager { .await .map_err(|_| anyhow!("vector::session::QuicManager: QUIC handshake timeout"))? .context("vector::session::QuicManager: QUIC handshake failed")?; + require_quic_nw2(&connection)?; let mut exporter = [0u8; crate::protocol::TLS_EXPORTER_LEN]; connection .export_keying_material(&mut exporter, EXPORTER_LABEL, b"") @@ -364,8 +393,9 @@ fn configure_quic_transport( config: &mut quinn::ClientConfig, idle_timeout: Duration, keepalive_interval: Duration, + morph_enabled: bool, ) -> Result<()> { - let flow_control = quic_flow_control()?; + let flow_control = transport_flow_control()?; let mut transport = quinn::TransportConfig::default(); transport.datagram_receive_buffer_size(Some(QUIC_DATAGRAM_BUFFER_SIZE)); transport.datagram_send_buffer_size(QUIC_DATAGRAM_BUFFER_SIZE); @@ -376,6 +406,7 @@ fn configure_quic_transport( transport.max_idle_timeout(Some(quinn::IdleTimeout::try_from(idle_timeout)?)); transport.keep_alive_interval(Some(keepalive_interval)); transport.congestion_controller_factory(Arc::new(quinn::congestion::BbrConfig::default())); + crate::transport::configure_morph_mtu(&mut transport, morph_enabled); config.transport_config(Arc::new(transport)); Ok(()) } diff --git a/src/vector/session/tls.rs b/src/vector/session/tls.rs index 9768457..0067bb1 100644 --- a/src/vector/session/tls.rs +++ b/src/vector/session/tls.rs @@ -2,9 +2,11 @@ // SPDX-License-Identifier: GPL-3.0-only use super::*; +use std::sync::atomic::AtomicUsize; +use tokio::sync::OnceCell; pub(in crate::vector) struct TlsManager { - endpoint: String, + endpoint: Option<(String, crate::common::AddressFamily)>, dialer_ip: String, tls: ClientTls, auth_key: AuthKey, @@ -12,24 +14,29 @@ pub(in crate::vector) struct TlsManager { stats: Arc, telemetry: Arc, latency: Arc, - up_mux: Mutex>, - down_mux: Mutex>, - up_mux_connect: Mutex<()>, - down_mux_connect: Mutex<()>, + mux: Mutex>>, mux_enabled: bool, } -#[derive(Clone, Copy)] -pub(in crate::vector) enum MuxDirection { - Up, - Down, -} - pub(in crate::vector) enum OpenedTls { Dedicated(Box), Mux(MuxStream), } +#[derive(Default)] +struct TlsMux { + handle: OnceCell, + pending: AtomicUsize, +} + +struct PendingMux(Arc); + +impl Drop for PendingMux { + fn drop(&mut self) { + self.0.pending.fetch_sub(1, Ordering::Relaxed); + } +} + impl TlsManager { pub(in crate::vector) fn new( config: &PortalClientConfig, @@ -39,7 +46,9 @@ impl TlsManager { signals: ClientSignals, ) -> Arc { Arc::new(Self { - endpoint: config.endpoint(), + endpoint: config + .tcp_endpoint() + .map(|endpoint| (config.remote.carrier_addr(endpoint), endpoint.family)), dialer_ip: config.dialer_ip.clone(), tls, auth_key: credentials.auth_key, @@ -47,19 +56,12 @@ impl TlsManager { stats: signals.stats, telemetry: signals.telemetry, latency: signals.latency, - up_mux: Mutex::new(Vec::new()), - down_mux: Mutex::new(Vec::new()), - up_mux_connect: Mutex::new(()), - down_mux_connect: Mutex::new(()), + mux: Mutex::new(Vec::new()), mux_enabled: config.mux.enabled(), }) } - pub(in crate::vector) async fn open( - self: &Arc, - flow_id: u32, - direction: MuxDirection, - ) -> Result { + pub(in crate::vector) async fn open(self: &Arc, flow_id: u32) -> Result { if !self.mux_enabled { return self .connect_lane() @@ -67,24 +69,57 @@ impl TlsManager { .map(Box::new) .map(OpenedTls::Dedicated); } - // Serializing stream admission per direction makes the C1 decision - // exact: the selected shard records its new stream before the next - // opener observes load. - let _opening = self.mux_connect(direction).lock().await; - if let Some(handle) = self.available_mux(direction).await { - return handle - .open_stream(flow_id) - .await - .map(OpenedTls::Mux) - .map_err(Into::into); - } - let lane = self.connect_lane().await?; + let pending = reserve_mux(&mut *self.mux.lock().await); + // Reservations include connecting slots, avoiding a cold-start stampede + // onto the first handshake to finish. OnceCell shares one initializer; + // cancellation lets another waiter retry without leaking a pool slot. + let handle = pending + .0 + .handle + .get_or_try_init(|| self.connect_mux(pending.0.clone())) + .await; + let handle = match handle { + Ok(handle) => handle.clone(), + Err(error) => { + let pool = self.mux.lock().await; + let Some(handle) = pool + .iter() + .filter_map(|carrier| carrier.handle.get()) + .filter(|handle| !handle.is_closed()) + .min_by_key(|handle| (handle.pressure(), handle.active_streams())) + .cloned() + else { + return Err(error); + }; + let stream = handle.prepare_stream(flow_id)?; + drop(pending); + drop(pool); + return handle + .open_prepared(stream) + .await + .map(OpenedTls::Mux) + .map_err(Into::into); + } + }; + // Registration and reservation release exclude idle retirement. + let pool = self.mux.lock().await; + let stream = handle.prepare_stream(flow_id)?; + drop(pending); + drop(pool); + handle + .open_prepared(stream) + .await + .map(OpenedTls::Mux) + .map_err(Into::into) + } + + async fn connect_mux(self: &Arc, slot: Arc) -> Result { let TlsLane { mut stream, pending_auth, _link, latency, - } = lane; + } = self.connect_lane().await?; stream .write_all(&pending_auth.expect("new TLS carrier has pending auth")) .await @@ -96,83 +131,52 @@ impl TlsManager { stream.flush().await?; let (handle, incoming) = MuxHandle::start(stream, MuxConfig::default())?; drop(incoming); - self.mux(direction).lock().await.push(handle.clone()); + // No await after start until ownership is handed to the lifetime task. let manager = self.clone(); let lifetime = handle.clone(); tokio::spawn(async move { - manager - .monitor_mux(direction, lifetime, _link, latency) - .await; + manager.monitor_mux(slot, lifetime, _link, latency).await; }); - handle - .open_stream(flow_id) - .await - .map(OpenedTls::Mux) - .map_err(Into::into) - } - - fn mux(&self, direction: MuxDirection) -> &Mutex> { - match direction { - MuxDirection::Up => &self.up_mux, - MuxDirection::Down => &self.down_mux, - } - } - - fn mux_connect(&self, direction: MuxDirection) -> &Mutex<()> { - match direction { - MuxDirection::Up => &self.up_mux_connect, - MuxDirection::Down => &self.down_mux_connect, - } - } - - async fn available_mux(&self, direction: MuxDirection) -> Option { - let mut muxes = self.mux(direction).lock().await; - muxes.retain(|handle| !handle.is_closed()); - select_available_mux(&muxes) + Ok(handle) } async fn monitor_mux( self: Arc, - direction: MuxDirection, - handle: MuxHandle, + slot: Arc, + carrier: MuxHandle, _link: LinkGuard, _latency: LatencyGuard, ) { loop { tokio::select! { - _ = handle.closed() => break, - idle = handle.idle_for(MUX_IDLE_TIMEOUT) => { - if !idle { - break; - } - } + _ = carrier.closed() => break, + idle = carrier.idle_for(MUX_IDLE_TIMEOUT) => { if !idle { break; } } } - let _opening = self.mux_connect(direction).lock().await; - if handle.active_streams() != 0 { + let mut pool = self.mux.lock().await; + if carrier.active_streams() != 0 || slot.pending.load(Ordering::Relaxed) != 0 { continue; } - self.remove_mux(direction, &handle).await; - handle.close(); + pool.retain(|candidate| !Arc::ptr_eq(candidate, &slot)); + carrier.close(); return; } - let _opening = self.mux_connect(direction).lock().await; - self.remove_mux(direction, &handle).await; - } - - async fn remove_mux(&self, direction: MuxDirection, handle: &MuxHandle) { - self.mux(direction) + self.mux .lock() .await - .retain(|candidate| !candidate.same_carrier(handle)); + .retain(|candidate| !Arc::ptr_eq(candidate, &slot)); } async fn connect_lane(&self) -> Result { + let (endpoint, family) = self + .endpoint + .as_ref() + .ok_or_else(|| anyhow!("vector::session::TlsManager: TCP carrier is not configured"))?; let (stream, exporter) = self .tls - .connect_tcp(&self.endpoint, &self.dialer_ip) + .connect_tcp(endpoint, &self.dialer_ip, *family) .await?; let latency = self.latency.register(); - latency.update_tcp(stream.get_ref().0); + latency.update_tcp(stream.get_ref().0.get_ref()); let auth = encode_auth_frame( self.auth_key, AuthTransport::TlsTcp, @@ -188,13 +192,30 @@ impl TlsManager { } } -fn select_available_mux(muxes: &[MuxHandle]) -> Option { - muxes +fn reserve_mux(pool: &mut Vec>) -> PendingMux { + pool.retain(|carrier| !carrier.handle.get().is_some_and(MuxHandle::is_closed)); + let selected = pool .iter() - .filter(|handle| !handle.is_closed()) - .min_by_key(|handle| handle.active_streams()) - .filter(|handle| handle.active_streams() < TLS_MUX_FLOWS_PER_SHARD) - .cloned() + .map(|carrier| { + let handle = carrier.handle.get(); + let active = carrier.pending.load(Ordering::Relaxed) + + handle.map_or(0, MuxHandle::active_streams); + let pressure = handle.map_or(0, MuxHandle::pressure); + (carrier, active, pressure) + }) + .min_by_key(|(_, active, pressure)| (*active != 0, *pressure, *active)); + let carrier = match selected { + Some((carrier, active, _)) if active == 0 || pool.len() >= TLS_MUX_MAX_CARRIERS => { + carrier.clone() + } + _ => { + let carrier = Arc::new(TlsMux::default()); + pool.push(carrier.clone()); + carrier + } + }; + carrier.pending.fetch_add(1, Ordering::Relaxed); + PendingMux(carrier) } #[cfg(test)] diff --git a/src/vector/socks/server.rs b/src/vector/socks/server.rs index cf4ebdc..8d5032b 100644 --- a/src/vector/socks/server.rs +++ b/src/vector/socks/server.rs @@ -39,7 +39,7 @@ use super::super::udp_flow::{UdpTunnel, open_udp}; mod udp; #[cfg(test)] -use self::udp::{accept_udp_source, validate_udp_source_request}; +use self::udp::{accept_udp_source, try_admit_udp_target, validate_udp_source_request}; use self::udp::{run_udp_association, start_access}; const TCP_LISTEN_BACKLOG: i32 = 1024; const SOCKS_UDP_PACKET_MAX: usize = u16::MAX as usize + 3 + 1 + 1 + 255 + 2; @@ -78,12 +78,12 @@ pub(in crate::vector) async fn serve_listener( _ = shutdown.cancelled() => break, accepted = listener.accept() => match accepted { Ok((stream, peer)) => { - let Ok(admission) = vector.socks_admission.clone().try_acquire_owned() else { + let Some(admission) = try_admit_client(&vector) else { vector.telemetry.emit_runtime( RuntimeEvent::new( RuntimeLevel::Warn, RuntimeKind::Listener, - "SOCKS client limit exceeded", + "SOCKS client resource limit reached", ) .with_client(peer.to_string()), ); @@ -119,6 +119,14 @@ pub(in crate::vector) async fn serve_listener( while clients.join_next().await.is_some() {} } +fn try_admit_client(vector: &Arc) -> Option { + vector + .socks_client_admission + .clone() + .try_acquire_owned() + .ok() +} + async fn handle_client( vector: Arc, mut stream: TcpStream, diff --git a/src/vector/socks/server/udp.rs b/src/vector/socks/server/udp.rs index ec23918..0259919 100644 --- a/src/vector/socks/server/udp.rs +++ b/src/vector/socks/server/udp.rs @@ -54,10 +54,9 @@ pub(super) async fn run_udp_association( let client_endpoint = Arc::new(StdMutex::new( requested_port.map(|port| SocketAddr::new(control_peer.ip(), port)), )); - let max_flows = crate::common::max_udp_flows(); - let mut flows: HashMap> = - HashMap::with_capacity(max_flows.min(64)); + let mut flows: HashMap> = HashMap::new(); let mut tasks = JoinSet::new(); + let active_targets = vector.socks_udp_target_admission.clone(); let mut packet = vec![0u8; SOCKS_UDP_PACKET_MAX]; let mut control_byte = [0u8; 1]; @@ -106,9 +105,10 @@ pub(super) async fn run_udp_association( } flows.remove(&target); } - if flows.len() >= max_flows { + flows.retain(|_, sender| !sender.is_closed()); + let Some(target_admission) = try_admit_udp_target(&active_targets) else { continue; - } + }; let (sender, receiver) = mpsc::channel(64); if sender.try_send(payload).is_err() { continue; @@ -121,6 +121,7 @@ pub(super) async fn run_udp_association( target, receiver, association_shutdown.clone(), + target_admission, )); } Some(_) = tasks.join_next(), if !tasks.is_empty() => { @@ -141,6 +142,7 @@ async fn open_and_relay_udp_target( target: SocksAddress, outbound: mpsc::Receiver, shutdown: CancellationToken, + _target_admission: OwnedSemaphorePermit, ) { let source = client_endpoint .lock() @@ -182,6 +184,12 @@ async fn open_and_relay_udp_target( .await; } +pub(super) fn try_admit_udp_target( + admission: &Arc, +) -> Option { + admission.clone().try_acquire_owned().ok() +} + pub(super) fn validate_udp_source_request( requested: &SocksAddress, peer_ip: IpAddr, @@ -353,7 +361,6 @@ pub(super) fn start_access( id: 0, timestamp_ms: now_unix_ms(), protocol, - alpn: vector.config.alpn.clone(), flow_id: None, session_tag: None, client, diff --git a/src/vector/tls.rs b/src/vector/tls.rs index b6e7476..f898ce1 100644 --- a/src/vector/tls.rs +++ b/src/vector/tls.rs @@ -7,7 +7,8 @@ use std::fmt; use std::sync::Arc; use anyhow::{Context, Result, anyhow, bail}; -use quinn::crypto::rustls::QuicClientConfig; +use quinn::Connection; +use quinn::crypto::rustls::{HandshakeData, QuicClientConfig}; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use rustls::crypto::{ WebPkiSupportedAlgorithms, ring, verify_tls12_signature, verify_tls13_signature, @@ -18,8 +19,11 @@ use tokio::net::TcpStream; use tokio::time::timeout; use tokio_rustls::{TlsConnector, client::TlsStream}; -use crate::common::{certificate_sha256, dial_tcp_from_local_ip, handshake_timeout}; -use crate::protocol::{TLS_EXPORTER_LEN, TlsExporter}; +use crate::common::{ + AddressFamily, certificate_sha256, dial_tcp_from_local_ip_family, handshake_timeout, +}; +use crate::protocol::{ALPN, TLS_EXPORTER_LEN, TlsExporter}; +use crate::transport::{MorphKeys, MorphTcpStream}; use super::config::PortalClientConfig; @@ -30,7 +34,7 @@ pub(super) const EXPORTER_LABEL: &[u8] = b"EXPORTER-Nowhere-Auth"; pub(super) struct ClientTls { rustls: Arc, server_name: ServerName<'static>, - alpn: Vec, + morph_keys: Option, } impl ClientTls { @@ -75,22 +79,16 @@ impl ClientTls { })) .with_no_client_auth() }; - let alpn = config.alpn.as_bytes().to_vec(); - client.alpn_protocols = vec![alpn.clone()]; + client.alpn_protocols = vec![ALPN.to_vec()]; client.enable_early_data = false; - let server_name = ServerName::try_from( - config - .sni - .as_deref() - .unwrap_or(config.remote_host.as_str()) - .to_owned(), - ) - .map_err(|_| anyhow!("vector::tls::ClientTls::new: invalid TLS server name"))?; + let server_name = + ServerName::try_from(config.sni.as_deref().unwrap_or(config.host()).to_owned()) + .map_err(|_| anyhow!("vector::tls::ClientTls::new: invalid TLS server name"))?; Ok(Self { rustls: Arc::new(client), server_name, - alpn, + morph_keys: config.morph_keys.clone(), }) } @@ -112,13 +110,16 @@ impl ClientTls { &self, endpoint: &str, dialer_ip: &str, - ) -> Result<(TlsStream, TlsExporter)> { - let stream = dial_tcp_from_local_ip(dialer_ip, endpoint, handshake_timeout()) - .await - .with_context(|| format!("vector::tls::connect_tcp: failed to dial {endpoint}"))?; + family: AddressFamily, + ) -> Result<(TlsStream>, TlsExporter)> { + let stream = + dial_tcp_from_local_ip_family(dialer_ip, endpoint, handshake_timeout(), family) + .await + .with_context(|| format!("vector::tls::connect_tcp: failed to dial {endpoint}"))?; stream .set_nodelay(true) .context("vector::tls::connect_tcp: failed to set TCP_NODELAY")?; + let stream = MorphTcpStream::client(stream, self.morph_keys.clone())?; let connector = TlsConnector::from(self.rustls.clone()); let tls = timeout( handshake_timeout(), @@ -127,18 +128,35 @@ impl ClientTls { .await .map_err(|_| anyhow!("vector::tls::connect_tcp: TLS handshake timeout"))? .context("vector::tls::connect_tcp: TLS handshake failed")?; + require_nw2(tls.get_ref().1.alpn_protocol()) + .context("vector::tls::connect_tcp: invalid negotiated protocol")?; let mut exporter = [0u8; TLS_EXPORTER_LEN]; tls.get_ref() .1 .export_keying_material(&mut exporter, EXPORTER_LABEL, Some(&[])) .context("vector::tls::connect_tcp: TLS exporter failed")?; - if tls.get_ref().1.alpn_protocol() != Some(self.alpn.as_slice()) { - bail!("vector::tls::connect_tcp: Portal did not negotiate the configured ALPN"); - } Ok((tls, exporter)) } } +pub(super) fn require_quic_nw2(connection: &Connection) -> Result<()> { + let handshake = connection + .handshake_data() + .ok_or_else(|| anyhow!("vector::tls: QUIC handshake data unavailable"))? + .downcast::() + .map_err(|_| anyhow!("vector::tls: unexpected QUIC handshake data"))?; + require_nw2(handshake.protocol.as_deref()) + .context("vector::tls: invalid QUIC negotiated protocol") +} + +fn require_nw2(alpn: Option<&[u8]>) -> Result<()> { + match alpn { + Some(ALPN) => Ok(()), + Some(_) => bail!("unsupported negotiated ALPN"), + None => bail!("peer did not negotiate ALPN"), + } +} + #[derive(Clone)] struct PinnedCertificateVerification { pin: String, diff --git a/src/vector/udp_flow.rs b/src/vector/udp_flow.rs index 32ee488..63caf57 100644 --- a/src/vector/udp_flow.rs +++ b/src/vector/udp_flow.rs @@ -9,7 +9,6 @@ use std::sync::atomic::Ordering; use anyhow::{Context, Result, bail}; use bytes::Bytes; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::sync::OwnedSemaphorePermit; use tokio::sync::mpsc; use tokio::time::timeout; @@ -34,7 +33,6 @@ pub(crate) struct UdpTunnel { _lanes: Vec, _lease: Option, _session: Option, - _flow_permit: Option, } impl UdpTunnel { @@ -186,11 +184,6 @@ pub(crate) async fn open_udp( target: &Target, hops: u8, ) -> std::result::Result { - let flow_permit = client - .udp_flow_permits - .clone() - .try_acquire_owned() - .map_err(|_| OpenFlowError::Setup(crate::protocol::SetupResult::FlowLimit))?; let lease = client .flow_ids .allocate() @@ -216,7 +209,6 @@ pub(crate) async fn open_udp( quic, mut down_datagrams, } = prepared; - if let Err(error) = setup_udp_lanes(&mut lanes, flow_id, route, target, hops).await { if let Some(quic) = &quic { quic.remove_udp(flow_id); @@ -272,7 +264,6 @@ pub(crate) async fn open_udp( _session: client .account_stats .then(|| SessionGuard::new(client.stats.clone(), true)), - _flow_permit: Some(flow_permit), _lanes: lanes, _lease: Some(lease), })