- 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.
-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
-
-
-
-
-
-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
+
+
+
+
+
+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