Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d789031863 | ||
|
|
20834d297e | ||
|
|
7b06dc7aac | ||
|
|
f7dff25f9d | ||
|
|
98afb092ce | ||
|
|
7991bf6ad0 | ||
|
|
57a728dc4b | ||
|
|
3df5c484c6 | ||
|
|
46d3a87b24 | ||
|
|
474844e563 | ||
|
|
514e0c1f25 | ||
|
|
3fb26d9de6 | ||
|
|
7d23a89791 | ||
|
|
d586a5d2b8 | ||
|
|
0789ab62cb | ||
|
|
52b6252531 | ||
|
|
f315b0e0ca | ||
|
|
c31dd5e100 | ||
|
|
0d6b23b7cf | ||
|
|
c86021e6a9 |
No files matched your search
+175
-1
@@ -4,6 +4,179 @@ All notable changes to this crate are documented here. The format is
|
||||
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2026-09-20
|
||||
|
||||
The graduation release: the direct op + the bind-first/forwarded
|
||||
metadata flow (ADRs 007/008), over alkcall 0.8.0. Composed against by
|
||||
alksocks (the SOCKS5 layer) and the planned `alknodes` assembly.
|
||||
|
||||
### Added
|
||||
|
||||
- **`TunnelListener` — the listen-flow consumer session (ADR-008 §4)** —
|
||||
the listen-flow sibling of `TunnelSession`: `TunnelListener::open`
|
||||
(rides `ChannelClient::open_channel_with_reply`, alkcall 0.8.0's
|
||||
U-2 surface — the full reply arrives and the bind-first `bound`/
|
||||
`listen` fields project through the lenient
|
||||
`listen_reply_fields`; adopt-free construction — the client already
|
||||
adopted the channel), `bound()`/`listen()` (the Option posture —
|
||||
`None` for dial/accept-first producers and old producers), and the
|
||||
listen channel's teardown ownership (ADR-005's posture extended to
|
||||
the listen flow): `close` (the graceful EOF sentinel via
|
||||
`shutdown`, then the reap) and `Drop` (the held write half drops →
|
||||
the mux pump's implicit-EOF chunk → the far accept dispatcher's
|
||||
teardown signal; sync reap — never leaks). The held `BiStream`'s
|
||||
write half stays IDLE and private (a listen channel has no
|
||||
data-plane role; pumping one is meaningless). No `Clone`
|
||||
(compile-asserted). Forwarded sessions are NOT owned by the
|
||||
listener — they arrive as ordinary adopted-side `TunnelSession`s on
|
||||
the opener's manager (`adopt` gains nothing, pinned by test); the
|
||||
assembly correlates them via the `listen()` token (the receipt
|
||||
queue is assembly-side bookkeeping).
|
||||
|
||||
- **The `local` feature's bind-first substrate half (ADR-008 §1,
|
||||
shape 3)** — the real-socket sources behind the committed
|
||||
`BindFn`/`BindFirstAcceptLoop` contracts (behind `local`; the
|
||||
default crate stays wasm-clean): `bind_tcp_first` (port 0 resolved
|
||||
via `local_addr` — the `bound` reply field's data) +
|
||||
`TcpListenerHalves::accept_pair` (one accept → `(peer, halves)` —
|
||||
the peer rides `accept()`'s return, the forwarded op's `peer`
|
||||
carrier); unix bind-first parity shipped (OQ-TN-14): `bind_unix` +
|
||||
`UnixListenerHalves` (`local_addr`, the queue-feeding `accept_loop`,
|
||||
the bind-first `accept_pair` — an unnamed (autobind) peer fails
|
||||
loud: the forwarded `peer` param requires an address) +
|
||||
`bind_unix_first`; the assembly conveniences `local_bind_first()`
|
||||
(tcp/unix dispatch; **udp fails loud — a UDP socket has no accept,
|
||||
the bind-first shape is not defined there, never a fake listener**)
|
||||
and `local_bindfirst_accept_loop()` (downcasts the plan's opaque
|
||||
listener to the module's wrappers; a foreign listener type fails
|
||||
loud as `handler_error`). The accept-FIRST shapes (`accept_loop` +
|
||||
`AcceptQueue`) are untouched. The direct-op `DialFn` reuse is
|
||||
verified + pinned: `local_dial` is shared unchanged (the UDP framed
|
||||
wrap happens inside the closure — review 001 U-1); a
|
||||
`TunnelSession::open_direct` + `Substrate::Udp` test drives a REAL
|
||||
UDP echo socket through the direct op's establisher path.
|
||||
|
||||
- **The forwarded op's SERVING side + the lenient reply projection
|
||||
(ADR-008 §3 as amended)** — the listen-opener half, the receipt
|
||||
counterpart to the bind-first producer (the opener SERVES the op;
|
||||
the producer CALLS it per accept): `ForwardedHandler` (the injected
|
||||
assembly handler — the parsed params + the channel `BiStream`; the
|
||||
assembly owns the pump, since the local halves are assembly state)
|
||||
+ `make_forwarded_op_handler` (the built-in wrapper: deny-unknown
|
||||
params parse (a malformed open fails LOUD → the wrapper returns
|
||||
early → teardown-at-birth reaps the channel — no phantom, the
|
||||
correct shape for a malformed ingress), `accept_bi` FIRST (R-02
|
||||
accepted-flag telemetry), then wrapper→assembly bridge, the
|
||||
injected handler's `JoinHandle` awaited inline); +
|
||||
`register_tunnel_forwarded_openable` (`register_openable` — no
|
||||
establisher, the data plane starts immediately; scope gate
|
||||
`tunnel:forwarded`, ingress — a separate grant from
|
||||
`tunnel:open`/`tunnel:direct`, never implied by either). Plus
|
||||
`listen_reply_fields` (the lenient, infallible session-surface
|
||||
projection of the bind-first reply — unknown extra reply fields
|
||||
tolerated, a malformed shape logs loud and surfaces both-`None`;
|
||||
shared with `TunnelListener`). Correlation-miss teardown semantics
|
||||
documented + test-pinned: the assembly holds the map, its error
|
||||
path drops the stream, the channel reaps (the producer's accept
|
||||
loop drops the accepted connection on the open's failure — the
|
||||
shapes mirror).
|
||||
|
||||
- **The bind-first establisher + accept dispatcher (shape 3, ADR-008
|
||||
as amended)** — the `-R`/ingress establisher whose establishment
|
||||
phase BINDS (the address-first-vs-accept-first deadlock dissolves;
|
||||
the accept wait lives in the teardown-governed pump phase):
|
||||
`BindFn` (the substrate bind function — the platform boundary, the
|
||||
protocol never binds) + `BoundListener` (the opaque listener + the
|
||||
real OS-chosen bound address); `bindfirst_establisher` (registry
|
||||
namespace gate → token mint → bind → `bound` + `listen` reply
|
||||
fields via `Establishment::with_reply_field` → the `BindFirstPlan`
|
||||
plan payload; the token mint is the pinned discipline — non-zero-
|
||||
seeded `AtomicU32`, per-open fresh, per-producer unique, token 0
|
||||
never minted); `BindFirstAcceptLoop` (the type-erased accept
|
||||
closure — receives the opaque listener, yields `(peer, halves)` per
|
||||
accept); `ForwardedOpener` (the per-accept forwarded-open caller —
|
||||
the call plane: `call_with_payload` on channel 0 +
|
||||
`manager.adopt_channel`, the reverse POC's shape);
|
||||
`make_bindfirst_accept_handler` (the dispatcher: `accept_bi` first
|
||||
per R-02, the idle listen-stream EOF cancels the loop and tears the
|
||||
listener down, per-accept `pump_bidi` tasks tracked by the handler's
|
||||
`JoinHandle` — a forwarded pump never outlives its listener;
|
||||
forwarded-open failures are per-accept isolated — log + drop, the
|
||||
loop continues, v1 no retry); `register_tunnel_bindfirst_openable`
|
||||
(same spec + `tunnel:open` scope gate as the dial/listen variants —
|
||||
the per-registration establisher posture; the injected seams ride
|
||||
`BindFirstInjections {bind, accept_loop, open_forwarded}` + the
|
||||
optional CF-006 identity witness). The opener-side forwarded-op
|
||||
serving shape (`register_openable`, no establisher) landed with
|
||||
`tunnels/forwarded-receipt`.
|
||||
|
||||
- **The forwarded op + the bind-first params surface (ADR-008 as
|
||||
amended)** — the wire-stable types pin first (the ordering
|
||||
`tunnels/params` used for the base op): `OP_TUNNEL_FORWARDED` /
|
||||
`TUNNEL_FORWARDED_SCOPE` constants; `TunnelForwardedParams
|
||||
{listen, substrate, peer}` (deny-unknown; `listen` is the
|
||||
producer-minted correlation token, positive by wire contract —
|
||||
schema `minimum: 1` + a serde-level pin) with a per-substrate
|
||||
`peer` sub-schema (malformed peers fail the registry's schema gate
|
||||
before the opener-side handler); `tunnel_forwarded_spec()`
|
||||
(`Sub`-typed, External, `alk/tunnel` ALPN marker, no establisher —
|
||||
the forwarded data plane starts immediately); `TunnelListenReply` +
|
||||
`parse_listen_reply` (the typed projection of the bind-first
|
||||
listen open's additive `bound`/`listen` reply fields — unknown
|
||||
reply fields tolerated, malformed ones fail loud via the new
|
||||
`TunnelError::MalformedReply`); `BindFirstPlan` (the opaque
|
||||
assembly-typed listener + the token — the plan payload the
|
||||
bind-first establisher/accept-dispatcher task consumes).
|
||||
|
||||
- **The direct op (`channels/tunnel/direct`, ADR-007)** — dynamic-
|
||||
target egress (the ssh `direct-tcpip` / SOCKS5 CONNECT shape),
|
||||
riding the same `alk/tunnel` ALPN: `SubstrateAddr` (the untagged
|
||||
substrate-shaped address object, also the forwarded op's `peer`
|
||||
shape) + `TunnelDirectParams {substrate, target}` with a
|
||||
per-substrate `input_schema` (malformed targets fail the registry's
|
||||
schema gate — `INVALID_INPUT` — before the establisher),
|
||||
`tunnel_direct_spec()`, `direct_establisher` (+ witness variant;
|
||||
no registry lookup — `unknown_resource` can never fire; targets
|
||||
render to the same backing-string form the registry backings use
|
||||
and ride the SAME injected `DialFn`), `register_tunnel_direct_
|
||||
openable` (same pump handler + plan-flow as the base op), and
|
||||
`TunnelSession::open_direct` (separate constructor — `tunnel:direct`
|
||||
is a separate grant from `tunnel:open`, never implied by it; the
|
||||
session type, data planes, and teardown API are identical). An old
|
||||
producer rejects the unknown op loudly (`NOT_FOUND`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **alkcall 0.7.1 → 0.8.0** (crates.io). The two upstream asks the
|
||||
graduation ADRs depend on landed (alkcall review 008): the
|
||||
establisher → reply projection (`Establishment::with_reply_field`,
|
||||
ADR-049 amendment 3 — the bind-first `bound` carrier) and
|
||||
flavor-form open-op ids in discovery (ADR-047 amendment 3 —
|
||||
`channels/tunnel/direct`-shaped ops reconstruct their
|
||||
`channel_open` marker through discovery, so hub relay wraps them as
|
||||
channels, not plain forwarding stubs). Plus the bonus:
|
||||
`ChannelRelay` + `HubLegTemplate` (ADR-051) — the hub re-produce
|
||||
machinery the graduation's re-produce story assumed would be
|
||||
assembly-layer is now in-tree, with `bound` flowing end-to-end
|
||||
per-hop-truthful (ADR-051 §6, ADR-008 §6's sentence realized).
|
||||
No alktunnels API or wire change in this bump; 71/91 tests pass,
|
||||
wasm-clean verified.
|
||||
|
||||
- **`BindFirstPlan` gains `substrate: Substrate` (review 002 M-1
|
||||
remediation)** — the accept dispatcher's silent
|
||||
`unwrap_or(Substrate::Tcp)` fallback (the second
|
||||
`TunnelParams` re-parse of wire input) is removed: the establisher
|
||||
stamps the parsed substrate into the plan (one parse, one source
|
||||
of truth) and the dispatcher reads it from the plan it already
|
||||
downcast. A drift can no longer silently stamp `Tcp` into a UDP
|
||||
listen tunnel's forwarded opens (a raw-vs-framed data-plane
|
||||
mismatch on the opener side). Plan-payload change is pre-consumer
|
||||
(no external `BindFirstPlan` consumer yet). Pinned by
|
||||
`forwarded_opens_carry_plan_stamped_substrate` (a UDP bind-first
|
||||
flow whose forwarded opens carry `substrate: udp` from the plan).
|
||||
|
||||
## [0.1.0] - 2026-09-10
|
||||
|
||||
The initial release: the v1 tunnel protocol implementation over
|
||||
@@ -48,5 +221,6 @@ complete (Phase 2, review 001 findings remediated).
|
||||
matrix). Wasm-clean default crate verified (`wasm32-unknown-unknown`
|
||||
check + clippy).
|
||||
|
||||
[Unreleased]: https://git.alk.dev/alkdev/alktunnels/compare/v0.1.0...HEAD
|
||||
[Unreleased]: https://git.alk.dev/alkdev/alktunnels/compare/v0.2.0...HEAD
|
||||
[0.2.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://git.alk.dev/alkdev/alktunnels/releases/tag/v0.1.0
|
||||
Generated
+3
-3
@@ -27,9 +27,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac26db919a1db9d72910ead8741af683bedefc625631028194b5f5e1dbd63cb6"
|
||||
checksum = "8695d47cee5b5a5fe68471f04a505bc257a7e7ed9e64d827de33ff83adf4db12"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alktunnels"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"alkcall",
|
||||
"bytes",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alktunnels"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -19,7 +19,7 @@ default = []
|
||||
local = ["tokio/net"]
|
||||
|
||||
[dependencies]
|
||||
alkcall = "0.7.1"
|
||||
alkcall = "0.8.0"
|
||||
tokio = { version = "1", default-features = false, features = ["rt", "sync", "io-util", "macros", "time"] }
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
|
||||
@@ -9,8 +9,14 @@ transport dependencies; the transport (QUIC, websocket, stdio, ...)
|
||||
is whatever alkcall runs over.
|
||||
|
||||
Downstream crates compose on top of it: wire your substrate's sockets
|
||||
into the producer's dial/accept closures (the `local` feature ships
|
||||
reference helpers), and open typed sessions from the consumer side.
|
||||
into the producer's dial/accept/bind closures (the `local` feature
|
||||
ships reference helpers), and open typed sessions from the consumer
|
||||
side. Dynamic-target egress (`ssh -W`/SOCKS5 CONNECT shape) rides the
|
||||
[`direct op`](https://docs.rs/alktunnels/latest/alktunnels/params/constant.OP_TUNNEL_DIRECT.html)
|
||||
(ADR-007); the composed `-R` flow (bind + peer metadata) rides the
|
||||
bind-first establisher + the
|
||||
[`forwarded op`](https://docs.rs/alktunnels/latest/alktunnels/params/constant.OP_TUNNEL_FORWARDED.html)
|
||||
(ADR-008).
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -76,6 +82,38 @@ let accept: AcceptFn = /* pops from the queue (or your own source) */;
|
||||
register_tunnel_listen_openable(&core, ®istry, &op_registry, auth, accept)?;
|
||||
```
|
||||
|
||||
### Producer — bind-first listen shape (`-R` with address + peer fidelity)
|
||||
|
||||
The same open op with a bind-first establisher: establishment BINDS
|
||||
(the reply carries the real OS-chosen `bound` address + a correlation
|
||||
token), then every local accept opens the `channels/tunnel/forwarded`
|
||||
op toward the listen opener with the real peer address.
|
||||
|
||||
```rust
|
||||
use alktunnels::producer::{
|
||||
register_tunnel_bindfirst_openable, BindFirstInjections,
|
||||
};
|
||||
|
||||
let injections = BindFirstInjections {
|
||||
// Binds the listener, returns it + the OS-chosen bound address.
|
||||
// Behind `local`: alktunnels::local::local_bind_first() (tcp/unix).
|
||||
bind,
|
||||
// Per accept: yields (peer, halves) from the opaque listener.
|
||||
// Behind `local`: alktunnels::local::local_bindfirst_accept_loop().
|
||||
accept_loop,
|
||||
// Opens the forwarded op on the channel-0 call plane + adopts.
|
||||
open_forwarded,
|
||||
};
|
||||
register_tunnel_bindfirst_openable(&core, ®istry, &op_registry, auth, injections, None)?;
|
||||
|
||||
// The forwarded op's opener-side serving half (on the consumer's
|
||||
// connection): correlate the token, pump against the local socket.
|
||||
register_tunnel_forwarded_openable(
|
||||
&core, &op_registry, auth,
|
||||
/* ForwardedHandler: (params, BiStream) -> pump task */,
|
||||
)?;
|
||||
```
|
||||
|
||||
### Consumer — open a tunnel channel (the `-L` path)
|
||||
|
||||
```rust
|
||||
@@ -135,6 +173,45 @@ let session = TunnelSession::adopt(
|
||||
session = session.pump_against(accepted_local_stream).await;
|
||||
```
|
||||
|
||||
### Consumer — dynamic target (the direct op, `ssh -W`/CONNECT shape)
|
||||
|
||||
```rust
|
||||
use alktunnels::params::SubstrateAddr;
|
||||
use alktunnels::TunnelSession;
|
||||
|
||||
// `tunnel:direct` is a SEPARATE grant from `tunnel:open` (never
|
||||
// implied by it) — the caller states which capability it holds. A
|
||||
// malformed target fails the schema gate; an old producer rejects
|
||||
// the unknown op loudly (NOT_FOUND).
|
||||
let mut session = TunnelSession::open_direct(
|
||||
&client,
|
||||
SubstrateAddr::HostPort { host: "example.internal".into(), port: 5432 },
|
||||
Substrate::Tcp,
|
||||
).await?;
|
||||
```
|
||||
|
||||
### Consumer — the listen flow (bind-first reply fields)
|
||||
|
||||
```rust
|
||||
use alktunnels::TunnelListener;
|
||||
|
||||
// The same `channels/tunnel/sub` open op — the establisher shape is
|
||||
// the producer's per-registration choice, invisible here. A bind-first
|
||||
// producer's reply carries `bound` (the real OS-chosen address) and a
|
||||
// correlation token; both are `None` for dial/accept-first producers
|
||||
// or old producers (the lenient, additive posture).
|
||||
let listener = TunnelListener::open(&client, params).await?;
|
||||
let bound = listener.bound().cloned(); // Option<SubstrateAddr> — informational, NOT addressing
|
||||
let token = listener.listen(); // Option<u32> — the forwarded op's correlation key
|
||||
|
||||
// Teardown: `close` sends the EOF sentinel (the far accept dispatcher
|
||||
// tears its listener down), then reaps; `Drop` is the sync best-effort
|
||||
// form. Forwarded sessions arrive as separate adopted `TunnelSession`s
|
||||
// on the manager — the assembly correlates them via the token; the
|
||||
// listener does not own them.
|
||||
listener.close().await;
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Two roles, symmetric per the alk convention (avoid "server"/"client":
|
||||
@@ -142,8 +219,9 @@ both sides of a connection can be either; role follows the resource):
|
||||
|
||||
| Role | Via `alk/channels` |
|
||||
|------|--------------------|
|
||||
| **Producer** (owns the backing: local ports, sockets, in-process services) | [`register_tunnel_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_openable.html) / [`register_tunnel_listen_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_listen_openable.html) — the open op (`channels/tunnel/sub`) with a **dial establisher** (target dialing) or a **listen establisher** (pops an accepted connection); the pump handler bridges the channel via [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) |
|
||||
| **Consumer** (wants the bytes) | [`TunnelSession`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html) — [`open`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open) (forward) / [`adopt`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.adopt) (reverse), substrate-shaped data planes, teardown ownership |
|
||||
| **Producer** (owns the backing: local ports, sockets, in-process services) | [`register_tunnel_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_openable.html) / [`register_tunnel_listen_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_listen_openable.html) / [`register_tunnel_bindfirst_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_bindfirst_openable.html) — the open op (`channels/tunnel/sub`) with a **dial establisher** (target dialing), an **accept-first listen establisher** (pops an already-accepted connection), or a **bind-first establisher** (binds; the reply carries `bound` + token, per-accept forwarded opens carry the peer); the pump handler bridges the channel via [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) |
|
||||
| **Producer** (dynamic-target egress) | [`register_tunnel_direct_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_direct_openable.html) — the direct op (`channels/tunnel/direct`, ADR-007) with its own scope (`tunnel:direct`); the target rides the params, no registry lookup |
|
||||
| **Consumer** (wants the bytes) | [`TunnelSession`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html) — [`open`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open) (forward) / [`open_direct`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open_direct) (dynamic target) / [`adopt`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.adopt) (reverse), substrate-shaped data planes, teardown ownership; [`TunnelListener`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelListener.html) — the listen flow with the `bound`/`listen` reply fields |
|
||||
|
||||
One connection hosts many tunnels (one per channel); sessions are
|
||||
independent. Both roles are callable from either connection side —
|
||||
@@ -151,9 +229,9 @@ independent. Both roles are callable from either connection side —
|
||||
machines (OQ-TN-03; the reverse POC validated the worker-serves /
|
||||
hub-initiates shape).
|
||||
|
||||
### A tunnel is a resource, not an address
|
||||
### A tunnel is a resource, not an address (with one additive exception)
|
||||
|
||||
The open-op params are wire-stable (ADR-001) and deliberately
|
||||
The base open-op params are wire-stable (ADR-001) and deliberately
|
||||
address-free:
|
||||
|
||||
```json
|
||||
@@ -165,6 +243,20 @@ never learns an address. Rich addressing (SOCKS5 ATYP, per-datagram
|
||||
remotes) enters only through the `-D`/dynamic composition path,
|
||||
speaking its protocol inside the tunnel payload — never in the wire.
|
||||
|
||||
The exception is the direct op (ADR-007) — a *different capability*
|
||||
on its own op + scope, so the ACL separation is structural:
|
||||
|
||||
```json
|
||||
{ "substrate": "tcp", "target": { "host": "10.0.0.7", "port": 5432 } }
|
||||
```
|
||||
|
||||
Scope-gated separately (`tunnel:direct` ≠ `tunnel:open` ≠
|
||||
`tunnel:forwarded` — never implied by each other); dial-only (no
|
||||
listen/direct-BIND form); the same `DialFn` the base op uses. The
|
||||
composed `-R` metadata flow rides the forwarded op
|
||||
(`channels/tunnel/forwarded`, `{listen, substrate, peer}` — the peer
|
||||
is producer-asserted metadata, not transport truth).
|
||||
|
||||
### Data plane
|
||||
|
||||
- **Stream substrates (`tcp`, `unix`)**: raw pass-through — the
|
||||
@@ -191,12 +283,29 @@ The default crate (no features) compiles to `wasm32-unknown-unknown`
|
||||
feature (real sockets) needs a real OS and is non-wasm by design;
|
||||
enabling it on wasm is a compile error.
|
||||
|
||||
## Version 0.2 — the graduation
|
||||
|
||||
0.2 rides alkcall 0.8.0 and graduates the proxy surface
|
||||
(`docs/research/tunnels-graduation.md`, ADRs 007/008):
|
||||
|
||||
- **The direct op** (`channels/tunnel/direct`, scope `tunnel:direct`)
|
||||
— dynamic-target egress; `TunnelSession::open_direct`.
|
||||
- **The bind-first establisher + the forwarded op**
|
||||
(`channels/tunnel/forwarded`, scope `tunnel:forwarded`) — the
|
||||
composed `-R` metadata flow: `bound` + correlation token in the
|
||||
listen reply, `{listen, substrate, peer}` per accept;
|
||||
`TunnelListener` + `parse_listen_reply`/`listen_reply_fields`.
|
||||
- **alkcall 0.7.1 → 0.8.0** — `Establishment::with_reply_field` +
|
||||
`open_channel_with_reply` (the bind-first carriers) and
|
||||
flavor-form op ids in discovery (hub relay wraps them as channels);
|
||||
bonus: in-tree `ChannelRelay`/`HubLegTemplate` (alkcall ADR-051).
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Contents | wasm |
|
||||
|---------|----------|------|
|
||||
| *(default)* | params, wire codec, open-op spec, establishers (dial + listen), `TunnelSession`, registration helpers — protocol only | yes |
|
||||
| `local` | [`dial_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_tcp.html) / [`bind_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_tcp.html) / [`connect_udp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.connect_udp.html) (the framed `UdpHalf`) / [`dial_unix`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_unix.html) / [`local_dial`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_dial.html), the TCP accept loop | no |
|
||||
| *(default)* | params, wire codec, open-op specs (`sub`/`direct`/`forwarded`), establishers (dial + accept-first listen + bind-first), `TunnelSession`, `TunnelListener`, registration helpers — protocol only | yes |
|
||||
| `local` | [`dial_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_tcp.html) / [`bind_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_tcp.html) / [`connect_udp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.connect_udp.html) (the framed `UdpHalf`) / [`dial_unix`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_unix.html) / [`local_dial`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_dial.html), the TCP accept loop; the bind-first halves: [`bind_tcp_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_tcp_first.html) / [`bind_unix_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_unix_first.html) + [`local_bind_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_bind_first.html) + [`local_bindfirst_accept_loop`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_bindfirst_accept_loop.html) | no |
|
||||
|
||||
Stdio is intentionally out of scope — a spawned process's stdio is
|
||||
alktty's pipe mode (exit codes + signals are tty semantics); remote
|
||||
|
||||
+46
-28
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: implemented
|
||||
last_updated: 2026-09-10
|
||||
status: draft
|
||||
last_updated: 2026-09-16 (the graduation ADRs 007/008 + the amendments)
|
||||
---
|
||||
|
||||
# alktunnels — Architecture
|
||||
@@ -18,15 +18,15 @@ raw byte/datagram transport, not terminal sessions).
|
||||
| Document | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| [overview.md](overview.md) | draft | Crate purpose, the resource model in brief, dependencies, ALPN, feature gates, module map |
|
||||
| [wire.md](wire.md) | draft | The wire format: open-op params JSON (ADR-001), the data-plane codec (raw pass-through for stream substrates; mandatory `[len: u16 BE]` framing for UDP — ADR-003), sentinels, half-close semantics |
|
||||
| [producer.md](producer.md) | draft | The producer half: the `channels/tunnel/sub` open op, the establisher (dial + listen variants), the pump handler, registration, scope gating |
|
||||
| [consumer.md](consumer.md) | draft | The consumer half: `TunnelSession` (stream + datagram variants), the reverse-flow initiator, teardown ownership (ADR-005) |
|
||||
| [wire.md](wire.md) | draft | The wire format: open-op params JSON (ADR-001; the direct op's `{substrate, target}` — ADR-007), the `bound` reply field + the forwarded op (ADR-008), the data-plane codec (raw pass-through for stream substrates; mandatory `[len: u16 BE]` framing for UDP — ADR-003), sentinels, half-close semantics |
|
||||
| [producer.md](producer.md) | draft | The producer half: the `channels/tunnel/sub` + `channels/tunnel/direct` open ops, three establisher shapes (dial, accept-first listen, bind-first listen), the pump handler, the forwarded-op opener side, registration, scope gating |
|
||||
| [consumer.md](consumer.md) | draft | The consumer half: `TunnelSession` (stream + datagram variants, `open`/`open_direct`), `TunnelListener` (the listen flow), the reverse-flow initiator, teardown ownership (ADR-005/008) |
|
||||
| [bast.md](bast.md) | draft | The BAST (Binary Abstract Syntax Tree) document for the data-plane's binary framing (the UDP `[len: u16 BE]` codec — ADR-003); the stream pass-through has no binary framing to describe |
|
||||
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-TN-01..14; the Phase 0 ledger promoted) |
|
||||
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-TN-01..16; the Phase 0 ledger promoted) |
|
||||
|
||||
## Applicable ADRs
|
||||
|
||||
alktunnels-native ADRs (001..005). Upstream ADRs (alkcall/alknet) are
|
||||
alktunnels-native ADRs (001..008). Upstream ADRs (alkcall/alknet) are
|
||||
referenced by their own numbers and are NOT ported — the alkcall
|
||||
originals at `/workspace/@alkdev/alkcall/docs/architecture/decisions/`
|
||||
remain authoritative for the shared surfaces (establishment, channels
|
||||
@@ -34,22 +34,28 @@ wire format, `pump_bidi`, identity seam).
|
||||
|
||||
| ADR | Title | Status |
|
||||
|-----|-------|--------|
|
||||
| [001](decisions/001-open-params-layout.md) | Open-Op Params Layout: Resource + Substrate | Accepted |
|
||||
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN (Option A) | Accepted |
|
||||
| [001](decisions/001-open-params-layout.md) | Open-Op Params Layout: Resource + Substrate | Accepted; amended (params layout is per-op — the direct op's params ride ADR-007) |
|
||||
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN (Option A) | Accepted; amended (flavor-form op ids: `…/sub`, `…/direct`, `…/forwarded` on the one ALPN) |
|
||||
| [003](decisions/003-codec-and-udp-framing.md) | Data-Plane Codec: Raw Pass-Through (Stream) / Mandatory Length Framing (UDP) | Accepted |
|
||||
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend Trait — Halves Functions at the Assembly Layer | Accepted |
|
||||
| [005](decisions/005-consumer-session-owns-teardown.md) | The Consumer Session Type Owns Teardown | Accepted |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-Control Posture — the Open Gate Is the Security Boundary | Accepted |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-Control Posture — the Open Gate Is the Security Boundary | Accepted; amended (`tunnel:direct`/`tunnel:forwarded` scopes; the hub re-produce policy sentence) |
|
||||
| [007](decisions/007-direct-open-op.md) | The Direct Open Op — Dynamic-Target Egress | Accepted |
|
||||
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen Metadata — Bind-First Establisher and the Forwarded Op | Accepted |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **A tunnel is a resource, not an address.** `params` identify a
|
||||
produced resource + substrate discriminator; the producer owns the
|
||||
backing (local port, docker container, in-process service, unix
|
||||
socket). No URL-style general addressing in the wire — rich
|
||||
addressing enters only through `-D`/dynamic composition, which
|
||||
speaks its protocol inside the tunnel payload. See
|
||||
[ADR-001](decisions/001-open-params-layout.md) and
|
||||
1. **A tunnel is a resource, not an address — with one additive
|
||||
exception.** Base params identify a produced resource + substrate
|
||||
discriminator; the producer owns the backing (local port, docker
|
||||
container, in-process service, unix socket). Rich addressing
|
||||
enters through `-D`/dynamic composition, which speaks its protocol
|
||||
inside the tunnel payload. The exception (ADR-007): the direct op
|
||||
lets a consumer ask the producer to dial an arbitrary target — a
|
||||
different capability on its own op and scope (`tunnel:direct`),
|
||||
never implied by `tunnel:open`. See
|
||||
[ADR-001](decisions/001-open-params-layout.md),
|
||||
[ADR-007](decisions/007-direct-open-op.md), and
|
||||
[wire.md](wire.md).
|
||||
|
||||
2. **Role follows the resource; connection direction is independent.**
|
||||
@@ -57,9 +63,11 @@ wire format, `pump_bidi`, identity seam).
|
||||
channels); whoever wants the bytes is the consumer. `-L` and `-R`
|
||||
are the same open op with the entry point on different machines —
|
||||
no protocol-level direction (the hub-owns-the-connection model,
|
||||
OQ-TN-03). Validated by the reverse-flow POC: the worker serves on
|
||||
the connect side; the hub initiates per accept. See
|
||||
[producer.md](producer.md) and [consumer.md](consumer.md).
|
||||
OQ-TN-03). The composed `-R` flow rides the bind-first establisher
|
||||
+ the forwarded op (ADR-008). Validated by the reverse-flow POC:
|
||||
the worker serves on the connect side; the hub initiates per
|
||||
accept. See [producer.md](producer.md) and
|
||||
[consumer.md](consumer.md).
|
||||
|
||||
3. **Raw pass-through for stream substrates; mandatory length framing
|
||||
for UDP.** A tunnel has one data stream per direction — no
|
||||
@@ -100,14 +108,19 @@ wire format, `pump_bidi`, identity seam).
|
||||
second demux/mux (AGENTS.md convention 10).
|
||||
|
||||
8. **Security posture: the open gate is the boundary.** Scope-gated
|
||||
opens (`TUNNEL_OPEN_SCOPE = "tunnel:open"`, stable once published
|
||||
— ADR-006), alkcall's op-level ACL via `register_openable`; caller
|
||||
identity resolves in the 0.7.0 precedence order (token >
|
||||
`ServingConfig.identity` > transport); the establisher/pump
|
||||
handler see the per-call opener identity. No target allowlists, no
|
||||
tunnel-specific policy machinery in v1. See
|
||||
[ADR-006](decisions/006-access-control-posture.md) and
|
||||
[producer.md](producer.md).
|
||||
opens (`TUNNEL_OPEN_SCOPE = "tunnel:open"`,
|
||||
`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`,
|
||||
`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"` — each stable once
|
||||
published; ADR-006 + Amendment 1), alkcall's op-level ACL via
|
||||
`register_openable`; caller identity resolves in the 0.7.0
|
||||
precedence order (token > `ServingConfig.identity` > transport);
|
||||
the establisher/pump handler see the per-call opener identity. No
|
||||
target allowlists in the protocol (an assembly establisher MAY
|
||||
enforce them — policy, not protocol). Hub re-produce: the hub's
|
||||
ACL governs the final dial (the hub is the network boundary). See
|
||||
[ADR-006](decisions/006-access-control-posture.md),
|
||||
[ADR-007](decisions/007-direct-open-op.md), and
|
||||
[producer.md](producer.md).
|
||||
|
||||
9. **Wasm-clean default crate.** The default crate (protocol only)
|
||||
compiles to `wasm32-unknown-unknown`; socket/platform I/O lives in
|
||||
@@ -140,6 +153,11 @@ Questions section.
|
||||
lifetime contract, R-03 = `pump_bidi`) and
|
||||
`/workspace/@alkdev/alkcall/docs/reviews/consumer-findings-ledger.md`
|
||||
(CF-005/CF-006 = the identity seam, resolved in 0.7.0)
|
||||
- The graduation research (the two new ADRs implement it):
|
||||
`docs/research/tunnels-graduation.md`; the upstream alkcall asks
|
||||
(review 008) **landed in alkcall 0.8.0** — flavor-form discovery
|
||||
derivation + the establisher → reply `bound` projection, plus the
|
||||
in-tree `ChannelRelay`/`HubLegTemplate` (alkcall ADR-051)
|
||||
- Sibling precedent: alktty (`/workspace/@alkdev/alktty`) — the
|
||||
producer/consumer-on-channels shape, wasm-clean default,
|
||||
feature-gated local backend, BAST convention
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
---
|
||||
status: implemented
|
||||
last_updated: 2026-09-10
|
||||
status: draft
|
||||
last_updated: 2026-09-18 (`open_direct`, `TunnelListener`, the forwarded-op serving side — ADR-007/008 as amended: the correlation token)
|
||||
---
|
||||
|
||||
# alktunnels — Consumer Half
|
||||
|
||||
The consumer half: `TunnelSession` — the typed client for tunnel
|
||||
channels. The consumer is whoever wants the bytes; it opens tunnel
|
||||
channels toward the producer (forward path), or per local accept
|
||||
toward a connect-side serving producer (the reverse flow). Both are
|
||||
the same session type — role follows the resource, not the connection
|
||||
(OQ-TN-03). Design decisions: [ADR-005](decisions/005-consumer-session-owns-teardown.md)
|
||||
(session owns teardown), [ADR-003](decisions/003-codec-and-udp-framing.md)
|
||||
(codec); this document is the normative WHAT.
|
||||
channels — plus `TunnelListener` (the listen-flow session, ADR-008)
|
||||
and the forwarded-op serving side. The consumer is whoever wants the
|
||||
bytes; it opens tunnel channels toward the producer (forward path), or
|
||||
per local accept toward a connect-side serving producer (the reverse
|
||||
flow). Both are the same session type — role follows the resource, not
|
||||
the connection (OQ-TN-03). Design decisions:
|
||||
[ADR-005](decisions/005-consumer-session-owns-teardown.md) (session
|
||||
owns teardown), [ADR-003](decisions/003-codec-and-udp-framing.md)
|
||||
(codec), [ADR-007](decisions/007-direct-open-op.md) (the direct op),
|
||||
[ADR-008](decisions/008-listen-metadata-forwarded-op.md) (listen
|
||||
metadata + the forwarded op); this document is the normative WHAT.
|
||||
|
||||
## Opening a Tunnel
|
||||
|
||||
@@ -36,6 +40,26 @@ let session = TunnelSession::open(&channel_client, params).await?;
|
||||
yields a session** — no phantom session, mirroring the
|
||||
no-phantom-channel property (POC-verified).
|
||||
|
||||
### Direct path (dynamic-target egress, ADR-007)
|
||||
|
||||
```rust
|
||||
let session = TunnelSession::open_direct(&client, target, substrate).await?;
|
||||
```
|
||||
|
||||
- A separate constructor from `open`, not a params enum: two scopes
|
||||
are two capabilities (`tunnel:direct` vs `tunnel:open`), and the
|
||||
caller states which one it holds. The session type, data planes,
|
||||
and teardown API are identical.
|
||||
- `target`: the substrate-shaped JSON object (`{host, port}` /
|
||||
`{path}` — ADR-007 §2); domain-form hosts stay unresolved (the
|
||||
producer resolves).
|
||||
- Flow: `open_channel("channels/tunnel/direct", {substrate, target},
|
||||
"alk/tunnel")` → same adopt/split/data-plane flow as `open`.
|
||||
- Errors: the same typed surface minus `unknown_resource` (no
|
||||
registry lookup); malformed targets are the schema/
|
||||
`invalid_input`-class failure. An old producer rejects the unknown
|
||||
op loudly (`NOT_FOUND` — the SSH posture).
|
||||
|
||||
### Reverse path (`-R` — the consumer initiates toward a connect-side serving producer)
|
||||
|
||||
The hub/accept-side shape (the reverse POC's `ReverseTunnel`, the
|
||||
@@ -72,6 +96,53 @@ let pumped = session.pump_against(accepted_halves).await; // or drive the halves
|
||||
The session type and its teardown API are identical on both paths —
|
||||
one API, `-L` and `-R` alike.
|
||||
|
||||
## The Listen Flow — `TunnelListener` (ADR-008)
|
||||
|
||||
The listen-flow sibling of `TunnelSession`: the session that OPENS a
|
||||
bind-first listen tunnel and owns its teardown (ADR-005's ownership
|
||||
posture extended to the listen flow). The forwarded connections it
|
||||
receives arrive as ordinary adopted-side `TunnelSession`s on the
|
||||
opener's manager (`adopt` gains nothing).
|
||||
|
||||
```rust
|
||||
let listener = TunnelListener::open(&client, params).await?; // bind-first: fast establishment
|
||||
let bound = listener.bound(); // the resolved OS-chosen address (Option — absent when unknown)
|
||||
let token = listener.listen(); // the correlation token (the producer's; from the `listen` reply field)
|
||||
let sessions = listener.forwarded(); // the forwarded sessions, as they arrive
|
||||
listener.close(self); // or Drop — tears the listen channel + far listener down
|
||||
```
|
||||
|
||||
- **`TunnelListener::open`** — the same `channels/tunnel/sub` open
|
||||
with `{resource, substrate}`; the establisher shape (bind-first vs
|
||||
accept-first) is the producer's per-registration choice, invisible
|
||||
to the constructor. What differs: bind-first replies carry
|
||||
`"bound"` (ADR-008 §2 — the resolved address; informational, never
|
||||
a dial target) and `"listen"` (ADR-008 Amendment 1 — the
|
||||
correlation token the producer minted).
|
||||
- **`bound()`** — `Option`-shaped: `Some` when the producer supplied
|
||||
the field (bind-first), `None` otherwise (accept-first, dial
|
||||
establishers, or old producers). reply#1-class fidelity (BND.ADDR).
|
||||
- **`listen()`** — `Option<u32>`: the correlation token (bind-first
|
||||
producers). The opener-side correlation registry — token → waiting
|
||||
session — is assembly-side bookkeeping (consumer.md's posture:
|
||||
the protocol provides the op and params, not the wait state
|
||||
machine); `TunnelListener` hands the assembly the key.
|
||||
- **The forwarded side:** each accepted connection at the far
|
||||
listener arrives as a producer-initiated
|
||||
`channels/tunnel/forwarded` open that the opener SERVES
|
||||
(`tunnel:forwarded` scope), its `listen` param matching the token.
|
||||
The opener-side assembly correlates the open to the waiting
|
||||
session, adopts the allocated channel (`TunnelSession::adopt`),
|
||||
and pumps against the local BIND client's halves.
|
||||
- **`peer` fidelity:** the forwarded op's params carry the accepted
|
||||
connection's real remote address (reply#2-class fidelity — the
|
||||
alksocks POC's sentinel retires). Producer-asserted metadata, not
|
||||
transport truth.
|
||||
- **Teardown:** dropping/`close`ing the listener EOFs the listen
|
||||
channel; the producer-side accept loop cancels and tears the far
|
||||
listener down (ADR-008 §1's handler contract). Forwarded sessions
|
||||
already adopted own their own teardown (per-session, ADR-005).
|
||||
|
||||
## The Data Plane (substrate-shaped)
|
||||
|
||||
- **Stream variant (`tcp`, `unix`):** `stream_halves()` — raw
|
||||
@@ -146,8 +217,12 @@ serving registry of its own.
|
||||
- **OQ-TN-12**: hub re-produce composition (deferred — a hub proxy
|
||||
re-exporting a consumed resource would add a
|
||||
`register_openable_with_establisher` whose establisher opens a
|
||||
consumer channel; no concrete consumer yet)
|
||||
consumer channel; no concrete consumer yet. The re-produce policy
|
||||
sentence is pinned: the hub's ACL governs the final dial — ADR-006
|
||||
Amendment 1)
|
||||
- **OQ-TN-13**: UDP truncation (resolved — fail-loud, ADR-003)
|
||||
- **OQ-TN-15/16**: resolved (ADR-007/008 — `open_direct`,
|
||||
`TunnelListener`, the forwarded op)
|
||||
|
||||
## References
|
||||
|
||||
@@ -155,6 +230,10 @@ serving registry of its own.
|
||||
[wire.md](wire.md) (the data plane)
|
||||
- Reverse POC `docs/research/reverse-poc-summary.md` (the seed shape:
|
||||
`ReverseTunnel` open_and_pump / close / join_and_reap; W3, W4)
|
||||
- `docs/research/tunnels-graduation.md` (the graduation research)
|
||||
- Upstream asks (review 008): `alkcall/docs/reviews/
|
||||
008-tunnels-graduation-upstream-review.md` — landed in alkcall
|
||||
0.8.0
|
||||
- alkcall ADR-047 §5 (allocation — the initiator adopts), ADR-050
|
||||
(`pump_bidi`), ledger
|
||||
CF-005/CF-006 (identity on the reverse path)
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-07)
|
||||
Accepted (2026-09-07); amended 2026-09-16 (params layout is per-op —
|
||||
the direct op pins its own params, see Amendment 1)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -117,4 +118,18 @@ carries (socks5, udpgw-style framing), never in base params.
|
||||
ADR-049 (typed establishment errors)
|
||||
- Forward POC: `docs/research/poc-summary.md` (the shape riding 17
|
||||
tests); reverse POC: `docs/research/reverse-poc-summary.md`
|
||||
- OQ-TN-01 (promoted), OQ-TN-11 (naming residue)
|
||||
- OQ-TN-01 (promoted), OQ-TN-11 (naming residue)
|
||||
|
||||
## Amendment 1 (params layout is per-op, 2026-09-16)
|
||||
|
||||
The `{resource, substrate}` layout pins the `channels/tunnel/sub` open
|
||||
op. It is **per-op, not per-ALPN**: ADR-007 adds the
|
||||
`channels/tunnel/direct` op on the same `alk/tunnel` ALPN with its own
|
||||
params (`{substrate, target}` — a dynamic target, a different
|
||||
capability with its own scope; the resource-not-address invariant is
|
||||
unchanged because a dynamic target is not a produced resource). The
|
||||
per-op reading was always latent in the layout's basis — params are
|
||||
"the `input` of the operation" (alkcall ADR-047), and ops are
|
||||
per-flavor once flavor-form op ids exist (ADR-002 Amendment 1).
|
||||
Additive-only evolution per op stands: this amendment adds nothing to
|
||||
the `…/sub` op's wire surface.
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-07)
|
||||
Accepted (2026-09-07); amended 2026-09-16 (flavor-form op ids on the
|
||||
one ALPN, see Amendment 1)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -83,4 +84,34 @@ session types).
|
||||
- OQ-TN-07 (promoted, resolved by this ADR)
|
||||
- `docs/research/ssh-socks5-survey.md` §ALPN-relevant prior art
|
||||
- alkcall ADR-004 (ALPN convention), ADR-047 (open ops per ALPN)
|
||||
- AGENTS.md convention 17 (ALPN naming)
|
||||
- AGENTS.md convention 17 (ALPN naming)
|
||||
|
||||
## Amendment 1 (flavor-form op ids, 2026-09-16)
|
||||
|
||||
The decision stands — one ALPN, no ALPN split per flavor. What
|
||||
changes: the one-ALPN rule is realized as **one ALPN, several
|
||||
`Sub`-typed open ops**, because ADR-007 (the direct op) and ADR-008
|
||||
(the forwarded op) are different capabilities sharing the tunnel
|
||||
transport — ssh's one-transport-many-channel-types shape.
|
||||
|
||||
- The op-id convention extends to flavor form: `channels/<alpn>/sub`
|
||||
(the original resource op, reserved and unchanged),
|
||||
`channels/<alpn>/direct` (dial an arbitrary target), and
|
||||
`channels/<alpn>/forwarded` (accept-as-open toward the listen
|
||||
opener). New flavors are new op ids — additive; the `…/sub` op is
|
||||
never reused for a different meaning.
|
||||
- The alternative — sibling ALPNs (`alk/tunnel-direct`,
|
||||
`alk/tunnel-forwarded`) with standard `…/sub` op ids — was rejected:
|
||||
it fragments the tunnel namespace per flavor, forces hub relay to
|
||||
track three ALPNs, and amends this ADR's own new-ALPN justification
|
||||
clause (the flavors share framing; none needs structurally different
|
||||
framing from byte zero).
|
||||
- Upstream coordination: alkcall ADR-047 pins discovery's ALPN
|
||||
derivation to exactly the `…/sub`/`…/pub` suffix shapes
|
||||
(`derive_alpn_from_op_name`); flavor-form op ids need that
|
||||
derivation (and the hub-relay reconstruction riding it) extended —
|
||||
filed upstream
|
||||
(`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`,
|
||||
ask #1) before the first consumer. Runtime was never blocked:
|
||||
registration sets the ALPN marker explicitly and
|
||||
`open_channel(op_id, params, alpn)` takes the ALPN as an argument.
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
Accepted (2026-09-07; records the OQ-TN-08 resolution as a decision —
|
||||
the posture was resolved in Phase 0 but the consumer-visible constants
|
||||
had no ADR backing)
|
||||
had no ADR backing); amended 2026-09-16 (scope separation for the
|
||||
direct/forwarded ops + the hub re-produce policy sentence, see
|
||||
Amendment 1)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -99,4 +101,31 @@ consumer-visible and stable-once-published:
|
||||
per-call opener); alknet ADR-024/ADR-050 (registry layering,
|
||||
ownership — the alkcall ports ADR-019/ADR-011)
|
||||
- alktty's `TTY_OPEN_SCOPE` posture (the sibling precedent)
|
||||
- AGENTS.md convention 13 (access control)
|
||||
- AGENTS.md convention 13 (access control)
|
||||
|
||||
## Amendment 1 (per-flavor scopes + hub re-produce policy, 2026-09-16)
|
||||
|
||||
The posture stands; the scope space grows with the ops (ADR-007,
|
||||
ADR-008):
|
||||
|
||||
- **`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`** gates
|
||||
`channels/tunnel/direct` — dynamic-target egress. **Never implied by
|
||||
`tunnel:open`**: "dial `postgres-primary`" and "dial anything from
|
||||
my network" are different capabilities, and the separation is
|
||||
structural (scopes attach to op specs, not params). A deployment
|
||||
granting both grants both explicitly.
|
||||
- **`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"`** gates
|
||||
`channels/tunnel/forwarded` on the opener side — the grant for
|
||||
RECEIVING producer-initiated forwarded connections (ingress).
|
||||
Ingress and egress are different grants (the alksocks POC #5
|
||||
`socks5:bind` gate proved the distinction matters in practice).
|
||||
- **Hub re-produce policy sentence (the security model):** a hub that
|
||||
re-produces the direct op applies ITS OWN target policy at the
|
||||
re-produce hop — the hub's ACL, not the original opener's, governs
|
||||
the final dial. The hub is the network boundary; the original
|
||||
opener's grant gets it to the hub, the hub's own grant decides what
|
||||
it will dial on the far side. Correct-by-default; pinned here
|
||||
because it is the boundary-allocation rule for every composed
|
||||
consumer (alknodes included).
|
||||
- Scope strings are ACL-stable once published (§2 unchanged): renames
|
||||
are breaking; new gates are new scopes on new ops.
|
||||
@@ -0,0 +1,238 @@
|
||||
# ADR-007: The Direct Open Op — Dynamic-Target Egress
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-16)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-001 pinned params as "resource, not an address": the producer owns
|
||||
the backing; rich addressing rides the `-D`/dynamic composition path,
|
||||
never the wire. That decision stands. The use case that justifies an
|
||||
*additive* revisit is the one the alksocks graduation research
|
||||
(`docs/research/tunnels-graduation.md`) named: a consumer asking the
|
||||
producer to dial an arbitrary target chosen at session time — ssh's
|
||||
`direct-tcpip` shape, SOCKS5 CONNECT's egress, and the vpn-like
|
||||
dial-callback translation all need it. A dynamic target is a different
|
||||
capability from opening a named resource, and ssh carries it as its own
|
||||
channel type precisely because the two capabilities must not share one
|
||||
grant.
|
||||
|
||||
The capability is dangerous in exactly the way ADR-001's "resource, not
|
||||
address" framing protects against — arbitrary egress from the
|
||||
producer's network. The mitigation is not refusing the capability (a
|
||||
SOCKS5 service IS the capability) but separating it at the ACL: "dial
|
||||
`postgres-primary`" and "dial anything" must not be the same grant.
|
||||
The alksocks POC #5 scope gate (`socks5:bind`) proved the pattern
|
||||
matters in practice.
|
||||
|
||||
Timing: alksocks is the first real consumer and nothing is published
|
||||
against the direct shape yet — this is the correct moment to pin the
|
||||
wire-stable surface.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. One ALPN, a second open op
|
||||
|
||||
The direct op rides the same `alk/tunnel` ALPN (ADR-002 stands: one
|
||||
ALPN, one protocol identity — SSH's one-transport-many-channel-types
|
||||
shape). The op id is **`channels/tunnel/direct`** — the flavor-form
|
||||
op-id convention (ADR-002 Amendment 1): `channels/<alpn>/<flavor>`,
|
||||
`Sub`-typed, same establishment phase and wrapper as `…/sub`. An older
|
||||
producer rejects the unknown op loudly (registry lookup fails — the
|
||||
SSH "unknown channel type" posture, `NOT_FOUND`).
|
||||
|
||||
The op-id convention itself needed an upstream alkcall amendment
|
||||
(ADR-047 amendment: discovery's ALPN derivation and the
|
||||
`rebuild_spec_for` marker path recognize flavor-form op ids; hub relay
|
||||
reconstructs them). Runtime was never blocked — registration sets the
|
||||
ALPN explicitly and `open_channel(op_id, params, alpn)` takes the ALPN
|
||||
as an argument — but discovery + hub relay are the re-produce path
|
||||
(alknodes, OQ-TN-12), so the convention is pinned upstream before the
|
||||
first consumer. **Landed in alkcall 0.8.0** (review 008 U-1, ADR-047
|
||||
amendment 3).
|
||||
|
||||
### 2. Params: `{ substrate, target }`
|
||||
|
||||
```json
|
||||
{
|
||||
"substrate": "tcp",
|
||||
"target": { "host": "db.internal", "port": 5432 }
|
||||
}
|
||||
```
|
||||
|
||||
- **`substrate`** (string, required) — same discriminator semantics as
|
||||
ADR-001: `"tcp"` | `"udp"` | `"unix"` in v1; selects the data-plane
|
||||
framing (ADR-003). Enum-validated; unknown values fail schema
|
||||
validation loudly.
|
||||
- **`target`** (object, required) — a self-describing JSON object keyed
|
||||
by the sibling `substrate` field:
|
||||
- `tcp` → `{ "host": <string>, "port": <u16> }` — `host` is a
|
||||
hostname, IPv4/IPv6 literal, or the JSON form of
|
||||
`SocketAddr`-shaped values; **domain-form targets stay unresolved
|
||||
on the wire** (producer-side resolution — the ssh `-D` semantic;
|
||||
both POCs validated egress-side resolution). No DNS dependency on
|
||||
the consumer.
|
||||
- `udp` → `{ "host": <string>, "port": <u16> }` — the connected-UDP
|
||||
destination (endpoint-at-open; ADR-003 framing applies as for base
|
||||
udp).
|
||||
- `unix` → `{ "path": <string> }` — the socket path on the
|
||||
producer's filesystem.
|
||||
- The schema pins both fields required; the target sub-schema is
|
||||
per-substrate (an `allOf` of two `if`/`then` branches keyed on the
|
||||
sibling `substrate` value, validated **registry-side** — the schema
|
||||
gate runs before the establisher; the establisher's serde parse
|
||||
re-checks as the backstop; see the Erratum below and Consequences).
|
||||
Malformed targets are the typed `invalid_input`-class failure;
|
||||
**`unknown_resource` can never fire on the direct op** (there is no
|
||||
registry lookup).
|
||||
- Encoding choice over the RFC-1928 ATYP byte string: the ATYP form is
|
||||
SOCKS5's wire carrier and alksocks maps `TargetAddr` → this JSON
|
||||
trivially (`to_target_addr` decomposes to host/port at the adapter),
|
||||
but a self-describing JSON object is schema-describable (alkcall
|
||||
ADR-047's registry `input_schema` runs before the establisher),
|
||||
carries the unix path form without an extension, and is
|
||||
human-readable in discovery listings and error messages. SOCKS5
|
||||
remains the reason the *capability* exists, not the carrier the
|
||||
*wire* encodes.
|
||||
- **Dial-only: no listen/direct-BIND form.** The reverse shape
|
||||
(`-R`-style ingress) is the listen-tunnel + the forwarded op
|
||||
(ADR-008) — a direct-BIND form would duplicate its capability with
|
||||
none of its metadata fidelity.
|
||||
|
||||
### 3. Scope: `TUNNEL_DIRECT_SCOPE = "tunnel:direct"`
|
||||
|
||||
The op's `AccessControl.required_scopes` is `["tunnel:direct"]` — a
|
||||
separate grant from `tunnel:open`, never implied by it. "Dial
|
||||
`postgres-primary`" and "dial anything from my network" are different
|
||||
capabilities; the ACL separation is structural (scopes attach to op
|
||||
specs, not params — ADR-006 §2's additivity rule). Scope strings are
|
||||
ACL-stable once published (ADR-006 §2): renames are breaking; new
|
||||
gates are new scopes on new ops.
|
||||
|
||||
### 4. Establisher: the dial shape without the registry
|
||||
|
||||
Same establisher contract as the dial shape (producer.md), minus the
|
||||
lookup:
|
||||
|
||||
1. Parse and validate `target` against `substrate` (a mismatch —
|
||||
`unix` without `path`, `tcp` with a bad port — is `invalid_input`
|
||||
class).
|
||||
2. Inject the parsed `TunnelTarget` into the assembly-provided
|
||||
`DialFn` (the same halves function the base op's registry backings
|
||||
resolve to; ADR-004 — the protocol never sees a socket type).
|
||||
3. Return the dialed handle via `Establishment::new(plan)` — unchanged
|
||||
plan-flow.
|
||||
|
||||
Error mapping (the dial table, minus the registry row):
|
||||
|
||||
| Producer condition | Typed reason |
|
||||
|---|---|
|
||||
| target malformed for the substrate | schema / `invalid_input`-class |
|
||||
| dial refused / timed out / unreachable | `dial_failed` |
|
||||
| bind/connect resource exhaustion | `resource_shortage` |
|
||||
| establisher-internal failure | `handler_error` |
|
||||
| the bound exceeded | `timeout` (wrapper-level) |
|
||||
|
||||
### 5. Consumer API
|
||||
|
||||
```rust
|
||||
let session = TunnelSession::open_direct(&client, target, substrate).await?;
|
||||
```
|
||||
|
||||
A separate constructor, not a params enum: two scopes are two
|
||||
capabilities, and the caller states which one it holds. The session
|
||||
type, data planes, and teardown API are identical to `open` (ADR-005).
|
||||
|
||||
### 6. Hub re-produce: the hub's policy governs the final dial
|
||||
|
||||
A hub that re-produces the direct op (terminate-and-re-produce, alkcall
|
||||
ADR-042) applies **its own** target policy at the re-produce hop: the
|
||||
hub's ACL, not the original opener's, governs the final dial. The hub
|
||||
is the network boundary; the original opener's `tunnel:direct` grant
|
||||
gets it to the hub, and the hub's own grant decides what it will dial
|
||||
on the far side. Correct-by-default; pinned here as the security model
|
||||
sentence (also ADR-006 amendment).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Wire-stable from the first consumer.** Op id, scope string, params
|
||||
field names, and the target sub-schema are one-way doors; renames
|
||||
are breaking. Additive evolution: a new substrate is a new `substrate`
|
||||
value + a new target key (never a format change); new fields inside
|
||||
a target object must be optional-with-default.
|
||||
- **`SubstrateAddr`'s untagged leniency is schema-gated** (review 002
|
||||
N-4): untagged serde variants cannot `deny_unknown_fields`, so a
|
||||
cross-shape target (e.g. `{path, host}`) parses at the serde layer —
|
||||
the SCHEMA's `additionalProperties: false` per branch is the real
|
||||
shape-vs-substrate gate. One caution for future registry variants: a
|
||||
schema-less registry would leave the establisher's serde backstop as
|
||||
the only gate, and it keys `render_target` off the sibling
|
||||
`substrate` — a cross-shape target that sneaked past would render
|
||||
*something* (`/p` for the unix case) rather than fail; the serde
|
||||
backstop renders the sibling-keyed target, so cross-shape garbage is
|
||||
the schema's absence, not a serde failure. If this crate is ever
|
||||
consumed against a schema-less registry variant, pin an ADR line
|
||||
there before the first such consumer.
|
||||
- **`SubstrateAddr` must not grow overlapping untagged variants.** If
|
||||
a future variant's shapes overlap (e.g. a `{host, port, flowlabel}`
|
||||
form), untagged disambiguation starts ordering (first-match wins
|
||||
becomes load-bearing and silent) — switch to an internally-tagged
|
||||
or untagged-with-content form at that point, deliberately (an ADR
|
||||
line), never by accident. Pre-consumer and additive until then.
|
||||
- **The BAST question is moot** — params is JSON inside the channels
|
||||
open op; the data plane is unchanged (raw pass-through for tcp/unix,
|
||||
the `[len: u16 BE]` codec for udp — ADR-003).
|
||||
- **Discovery exposure**: the direct op's `input_schema` carries
|
||||
`{substrate, target}`, so a discovery listing reveals that dynamic
|
||||
egress exists and of which substrates — acceptable; the target
|
||||
values (what was dialed) never enter discovery (they ride per-call
|
||||
params). A producer wanting to hide the capability entirely simply
|
||||
does not register the op (and old producers reject it loudly anyway).
|
||||
- **`local` feature interaction:** the direct establisher composes the
|
||||
same `DialFn` the base op uses; a producer that registered
|
||||
`dial_tcp` for the base op can register the direct op with no new
|
||||
substrate code. The `local` feature ships the dial functions; the
|
||||
direct op itself is protocol-layer (default crate, wasm-clean).
|
||||
- **SOCKS5 CONNECT composes directly:** the alksocks adapter's
|
||||
`DialFn` seam maps the SOCKS5 CONNECT target (ATYP-decoded) onto
|
||||
`open_direct` — the graduation's §4 table, now with the stand-in
|
||||
dissolved.
|
||||
|
||||
## Erratum (2026-09-20, review 002 D-2)
|
||||
|
||||
§2's original wording said the target sub-schema is "`oneOf` on the
|
||||
sibling `substrate` value, validated at establisher time". Both
|
||||
halves of that sentence were stale against the shipped schema
|
||||
(the decision itself was not — the code is the wire-stable side and
|
||||
is correct):
|
||||
|
||||
1. **The shipped structure is an `allOf` of two `if`/`then`
|
||||
branches**, not `oneOf`. `oneOf` of full-target-schemas would
|
||||
actually be wrong here: a `unix`-substrate open would need to FAIL
|
||||
the `tcp` branch, and plain `oneOf` on sibling-conditioned
|
||||
subschemas is awkward (exactly-one-branch-matches semantics does
|
||||
not compose with the base `properties`/`required`);
|
||||
`if`/`then` composes correctly with them.
|
||||
2. **Validation is registry-side, not establisher-time.** The
|
||||
registry's `input_schema` gate runs BEFORE the establisher
|
||||
(`malformed_target_is_schema_class_and_leaves_no_channel` asserts
|
||||
the establisher's dial never runs on a malformed target); the
|
||||
establisher's serde parse re-checks as the backstop. §2's text
|
||||
above is corrected in place; wire.md §The Direct Op carries the
|
||||
same corrected sentence.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/tunnels-graduation.md` §2 (the ask, options, and the
|
||||
lean) — the design discussion this ADR records
|
||||
- alksocks `poc-bind-findings.md` (the scope-gate precedent) and
|
||||
`poc-connect-wasm-findings.md` (the dial-at-command-read layering)
|
||||
- alkcall ADR-047 (open ops + `input_schema` + discovery marker),
|
||||
ADR-049 (establishment/typed errors), Gap F (op-name → ALPN
|
||||
derivation — the flavor-form amendment target)
|
||||
- alktunnels ADR-001 (the resource-params invariant the direct op
|
||||
extends), ADR-002 Amendment 1 (flavor-form op ids), ADR-006
|
||||
(scope posture + the hub-policy amendment)
|
||||
- AGENTS.md conventions 9 (no forced binding — dial-only, no listen
|
||||
form), 13 (access control), 17 (ALPN naming)
|
||||
@@ -0,0 +1,341 @@
|
||||
# ADR-008: Listen Metadata — Bind-First Establisher and the Forwarded Op
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-16); amended 2026-09-18 (the correlation token —
|
||||
`listen` reply field + forwarded-params key, see Amendment 1)
|
||||
|
||||
## Context
|
||||
|
||||
alksocks' BIND POC (`poc-bind-findings.md` in the alksocks repo) pinned
|
||||
two upstream asks for composed (`-R`-style) BIND fidelity:
|
||||
|
||||
1. **Bound-addr discovery without consuming an accept** — reply#1's
|
||||
`BND.ADDR` must precede the app server's dial, but the listen
|
||||
establisher is accept-first: its establishment phase pops an
|
||||
already-accepted connection, so the open cannot complete (and
|
||||
cannot report a bound address) until the app server dials.
|
||||
2. **Peer metadata on accepted handles** — reply#2's peer address is
|
||||
lost through the composed path; `TargetHandle` carries boxed halves
|
||||
only, so the composed variant reports the sentinel.
|
||||
|
||||
The graduation research (`docs/research/tunnels-graduation.md` §3)
|
||||
framed the option space: per-ask carriers (a reply-metadata projection
|
||||
plus one of three addr-discovery shapes) versus ssh's
|
||||
`forwarded-tcpip` shape as the combined alternative. Decision: the
|
||||
`forwarded-tcpip` shape — **bind-first establisher + accept-as-open**
|
||||
— because it resolves both asks *and* two structural problems the POC
|
||||
recorded, with zero special carriers:
|
||||
|
||||
- The address-first-vs-accept-first **deadlock dissolves** instead of
|
||||
being sequenced around: establishment = bind the listener (fast,
|
||||
no app-server dependency), so the open-op reply carries the **real**
|
||||
OS-chosen bound address (ask #1 solved natively).
|
||||
- The **10s establishment-bound problem dissolves**: the accept wait
|
||||
moves out of the establishment phase into the pump-handler phase
|
||||
(whose lifetime is teardown-governed, not establishment-bounded) —
|
||||
FTP-style long BIND waits stop resolving as spurious refusals.
|
||||
- Ask #2 (peer metadata) rides the reverse channel's open-op params —
|
||||
the wire's native "this connection is for X" moment.
|
||||
|
||||
All the machinery is POC-validated: `open_reverse_channel` +
|
||||
`TunnelSession::adopt` + producer-allocated IDs (the reverse POC), the
|
||||
listen establisher + `AcceptQueue` (POC #5), per-accept channel opens
|
||||
over a serving connection (the reverse POC's hub shape).
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Third establisher shape: bind-first
|
||||
|
||||
The listen establisher gains a bind-first variant alongside the
|
||||
accept-first shape (which stays — see §5):
|
||||
|
||||
- The establisher **binds the listener** (via the assembly-provided
|
||||
listen function) and returns `Establishment::new(plan)` carrying the
|
||||
listener handle. Establishment is fast — bounded by the bind, not by
|
||||
any app-server dial.
|
||||
- **The establisher mints a correlation token** (Amendment 1): a fresh
|
||||
u32 unique per open on this producer, carried twice — inside the
|
||||
plan (the pump handler needs it to correlate per-accept forwarded
|
||||
opens) and as the additive `listen` reply field (§2). The plan also
|
||||
carries the listen tunnel's substrate, stamped at establisher time
|
||||
from the one successful params parse — the accept dispatcher reads
|
||||
it from the plan and stamps it into each forwarded open's
|
||||
`substrate` param; it never re-parses wire input (review 002 M-1:
|
||||
the removed silent `Tcp` fallback). The original
|
||||
§3 key (the listen channel's ID) is superseded: the serving-side
|
||||
open-op wrapper never exposes the allocated channel ID to the
|
||||
establisher or handler (alkcall 0.8.0's `OpenHandler` surface), and
|
||||
a generic hub relay forwards params untouched (it strips/rewrites
|
||||
only the reply's `channel_id`), so a spoke's forwarded open would
|
||||
carry an id the end opener never allocated. A producer-minted token
|
||||
rides both hops untouched — reply fields pass through relays
|
||||
per-hop-truthful (alkcall ADR-051), and the same minted token
|
||||
appears in every hop's reply.
|
||||
- The establisher MAY contribute the observed bound address to the
|
||||
open-op reply (§2): `"bound": { ... }` — the real OS-chosen address
|
||||
(port `0` resolved), substrate-shaped like the direct op's target
|
||||
(`{host, port}` for tcp/udp, `{path}` for unix).
|
||||
- The pump handler for this shape is an **accept dispatcher**, not
|
||||
`pump_bidi`: it accepts the channel's `BiStream` (the R-02 contract)
|
||||
and runs the assembly-provided accept loop concurrently — each
|
||||
accepted connection produces a forwarded channel (§3); the channel's
|
||||
own `BiStream` stays idle and its EOF (the opener dropping the
|
||||
listener session) cancels the loop and tears the listener down. The
|
||||
idle-read check is deliberately WIDER than a bare EOF comparison
|
||||
(review 002 N-2): the watch treats ANY read as teardown — `Ok(0)`
|
||||
(the EOF sentinel), an error, **and** any non-empty read (a peer
|
||||
that writes to the idle listen channel is misbehaving; the channel
|
||||
is not a data plane — fail closed). An implementer reading only
|
||||
"its EOF cancels the loop" and checking `Ok(0)` alone would hang on
|
||||
a write-happy peer; the shipped posture is the any-byte one,
|
||||
pinned by the write-happy-peer test.
|
||||
|
||||
### 2. The `bound` reply field (upstream ask)
|
||||
|
||||
The open-op reply gains an **optional, additive** field:
|
||||
|
||||
```json
|
||||
{ "channel_id": 7, "bound": { "host": "203.0.113.9", "port": 42113 } }
|
||||
```
|
||||
|
||||
- Present only when the establisher supplies it (bind-first listeners);
|
||||
absent for dial establishers — old consumers ignore unknown reply
|
||||
fields (plain JSON, alkcall `ResponseEnvelope`), so the change is
|
||||
wire-additive.
|
||||
- The carrier is an upstream alkcall ask (ADR-049 amendment 3:
|
||||
establisher → reply projection — `Establishment` gains optional
|
||||
reply fields the wrapper merges into the success output; the
|
||||
`channel_id` key is wrapper-reserved). Filed in
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`.
|
||||
**Landed in alkcall 0.8.0** (review 008 U-2, ADR-049 amendment 3:
|
||||
`Establishment::with_reply_field` + the wrapper merge with the
|
||||
reserved-key guard).
|
||||
- **`listen` — the correlation token (Amendment 1).** A bind-first
|
||||
establisher also contributes `"listen": <u32>`: the token the
|
||||
forwarded op's params key on (§3, as amended). Same projection
|
||||
carrier, same additive posture — a second reply field on the same
|
||||
opens that carry `bound`. `bound` and `listen` are independent
|
||||
fields (an accept-first establisher could supply `listen`-shaped
|
||||
correlation too — the accept queue's establisher mints its own —
|
||||
but v1 pins the pair on bind-first only).
|
||||
- `bound` is informational surface, not addressing: a consumer MUST
|
||||
NOT treat it as a dial target (the producer owns the backing —
|
||||
ADR-001's invariant). It exists for RFC-1928-reply-class fidelity
|
||||
(BND.ADDR) and observability.
|
||||
|
||||
### 3. The forwarded op: `channels/tunnel/forwarded`
|
||||
|
||||
When the listener accepts, the **producer opens a channel back toward
|
||||
the listen-opener** — accept-as-open, the `forwarded-tcpip` shape:
|
||||
|
||||
- Op id **`channels/tunnel/forwarded`** (flavor-form, ADR-002
|
||||
Amendment 1), `Sub`-typed, ALPN marker `alk/tunnel`. It is served by
|
||||
the **listen-opener side** (the initiator of the listen-tunnel
|
||||
open): the producer *calls* it per accept
|
||||
(`open_reverse_channel`-shaped — the call plane on channel 0), the
|
||||
opener's registry allocates the `channel_id`, the producer adopts it
|
||||
(the ADR-047 §5 connection-owner rule — the serving side of the
|
||||
forwarded call allocates; the producer-side initiator adopts) and
|
||||
pumps the accepted connection's halves against the adopted channel.
|
||||
- Params:
|
||||
|
||||
```json
|
||||
{
|
||||
"listen": 42113,
|
||||
"substrate": "tcp",
|
||||
"peer": { "host": "198.51.100.4", "port": 55231 }
|
||||
}
|
||||
```
|
||||
|
||||
- **`listen`** (u32, required) — the correlation token the
|
||||
bind-first establisher minted and returned in the listen open's
|
||||
reply (§2, as amended). The opener matches it against the
|
||||
`listen` field of its open listener sessions — a `HashMap<u32, ..>`
|
||||
on the opener side; no registry op, no extra wire field. The
|
||||
token space is the producer's: fresh per open, unique on that
|
||||
producer (the opener-side map is keyed by the producer's token,
|
||||
and one opener's map may hold tokens from several producers — a
|
||||
hash collision across producers is the opener's bookkeeping
|
||||
choice, e.g. keying `(token)` uniquely or `(producer, token)`;
|
||||
v1 leaves it assembly-side). **Amends the original `channel`
|
||||
key** (the listen channel's ID): that semantic is unimplementable
|
||||
as pinned — the serving-side wrapper never exposes the allocated
|
||||
channel ID to the establisher or handler (alkcall 0.8.0's
|
||||
`OpenHandler` surface), and generic hub relays forward params
|
||||
untouched, so a spoke's forwarded open would carry an id the end
|
||||
opener never allocated (§1's Amendment 1 note has the full
|
||||
rationale). Tokens minted by the producer ride relays untouched:
|
||||
the reply field passes through per-hop truthful (alkcall ADR-051),
|
||||
and the forwarded open carries the same minted value at every
|
||||
hop.
|
||||
- **`substrate`** (string, required) — the listen tunnel's
|
||||
substrate; shapes the forwarded channel's data-plane framing
|
||||
(ADR-003), same discriminator semantics as everywhere else.
|
||||
- **`peer`** (object, required) — the accepted connection's remote
|
||||
address, substrate-shaped (`{host, port}` / `{path}`). This is ask
|
||||
#2's carrier: reply#2-class fidelity rides the open op, the wire's
|
||||
native metadata moment. Optional in kind, required in v1 — a
|
||||
producer that cannot know the peer (rare; all POC'd listeners
|
||||
can) omits... no: **required**; a producer that cannot determine
|
||||
the peer does not serve a bind-first listener over that substrate.
|
||||
- Scope: **`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"`** — the
|
||||
opener-side grant for RECEIVING forwarded connections (ingress).
|
||||
Separate from `tunnel:open` and `tunnel:direct` (ADR-006 amendment):
|
||||
exposing a listener and dialing egress are different capabilities.
|
||||
- The opener-side serving shape: `register_openable_with_establisher`
|
||||
as-is (no establisher — the forwarded channel needs no
|
||||
establishment; its data plane starts immediately), ACL-gated by the
|
||||
scope, handler = correlate the open (via `listen`, the correlation
|
||||
token) to the waiting
|
||||
session, accept the `BiStream`, pump against the local BIND client's
|
||||
halves. The correlation registry is assembly-side bookkeeping —
|
||||
the protocol provides the op and the params, not the
|
||||
wait/quiet-state machine.
|
||||
- An older opener that does not serve the forwarded op rejects the
|
||||
producer's per-accept call loudly (`NOT_FOUND`) — the SSH posture.
|
||||
A producer serving bind-first SHOULD prefer openers that advertise
|
||||
the forwarded op (discovery, alkcall ADR-047).
|
||||
|
||||
### 4. Consumer API
|
||||
|
||||
```rust
|
||||
let listener = TunnelListener::open(&client, params).await?; // reply carries `bound` + `listen`
|
||||
let bound = listener.bound(); // the resolved address
|
||||
// ... per forwarded accept (opener side, served op):
|
||||
// correlate by `listen`, accept the BiStream, pump against the
|
||||
// local session's halves
|
||||
listener.close(self); // or Drop — tears the listen channel + far listener down
|
||||
```
|
||||
|
||||
`TunnelListener` is the listen-flow sibling of `TunnelSession`: it owns
|
||||
the listen channel's teardown (ADR-005's ownership posture extended to
|
||||
the listen flow); it presents `bound()` and the teardown API, and the
|
||||
forwarded channels themselves arrive as separate adopted-side
|
||||
`TunnelSession`s on the opener's manager. `TunnelSession::adopt` gains
|
||||
nothing — forwarded channels are ordinary adopted channels. The
|
||||
opener-side correlation registry (token → waiting listener session)
|
||||
is assembly-side bookkeeping (consumer.md's posture); the crate's
|
||||
`TunnelListener` type provides the `listen` token it needs to key it.
|
||||
|
||||
### 5. The accept-first shape stays
|
||||
|
||||
`listen_establisher` + `AcceptQueue` (the POC #5 composed shape)
|
||||
remains a valid establisher shape for producers whose resource backing
|
||||
IS an accept queue (an externally-fed or pre-bound listener shared
|
||||
across opens). Bind-first is the shape for producers that own the
|
||||
bind decision (the common hub case; alksocks' composed BIND). The two
|
||||
shapes are per-registration assembly choices over the same op — no
|
||||
wire distinction beyond `bound`'s presence.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Wire-stable from the first consumer:** the `bound` and `listen`
|
||||
reply fields, the forwarded op id, its params, and `tunnel:forwarded`
|
||||
are one-way doors. The reply fields are additive (absent = old-producer
|
||||
compatible); the forwarded op is additive (old openers reject loudly).
|
||||
- **Upstream coordination resolved:** the `bound`-projection ask
|
||||
(ADR-049 amendment 3) and the flavor-form discovery derivation
|
||||
(ADR-047 amendment) **landed in alkcall 0.8.0** (review 008, Units
|
||||
1–2); the in-tree `ChannelRelay` + `HubLegTemplate` (ADR-051) came
|
||||
with them — the hub re-produce machinery §6 assumed assembly-layer
|
||||
is now upstream, and the relay passes reply fields through per-hop
|
||||
truthful (which is what makes the Amendment 1 token compose through
|
||||
hubs with zero tunnel-specific relay behavior). The bind-first
|
||||
establisher is this crate's remaining implementation work
|
||||
(tasks/tunnels).
|
||||
- **`TargetHandle` does NOT gain a `peer` field.** The option-(i)
|
||||
carrier from the graduation doc is superseded: with accept-as-open,
|
||||
the peer rides the forwarded op's params; no process-local metadata
|
||||
field, no sentinel. (If a future non-forwarded accept shape needs
|
||||
metadata, revisit then — additive.)
|
||||
- **The BIND reply#2 fidelity is end-to-end:** the opener-side
|
||||
correlation writes reply#2 with the REAL peer from the forwarded
|
||||
op's params. reply#1's `BND.ADDR` is the `bound` reply field. Both
|
||||
POC stand-ins (carried `bind_addr`; sentinel `0.0.0.0:0`) retire.
|
||||
- **Hub re-produce:** a hub re-producing a listen resource binds its
|
||||
own listener (bind-first, its own `bound` + minted `listen` token)
|
||||
and re-opens forwarded channels with the same token — the spoke's
|
||||
forwarded opens carry the hub's token untouched (params are not
|
||||
rewritten by the generic relay), and the metadata is per-hop
|
||||
truthful, matching alkcall ADR-042's terminate-and-re-produce
|
||||
posture. The end opener correlates on the hub's minted token — the
|
||||
value it read from its own open's reply.
|
||||
- **Local-variant BIND needs none of this** (the listener and the
|
||||
accept are in-process — POC #5 variant 1, unchanged); the asks and
|
||||
this ADR are scoped to the composed variant.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/tunnels-graduation.md` §3/§3.1 (the ask ledger and
|
||||
the `forwarded-tcpip` alternative this ADR adopts)
|
||||
- alksocks `poc-bind-findings.md` (the two asks with evidence; the
|
||||
deadlock and establishment-bound findings this shape dissolves)
|
||||
- alkcall ADR-047 (open ops; connection-owner allocation; the
|
||||
flavor-form amendment), ADR-049 (establishment; the reply-projection
|
||||
amendment), ADR-042 (hub relay — re-produce id semantics)
|
||||
- alktunnels ADR-001 (params per-op amendment), ADR-002 Amendment 1
|
||||
(flavor-form op ids), ADR-005 (teardown ownership — extended to
|
||||
`TunnelListener`), ADR-006 (scope posture amendment)
|
||||
- AGENTS.md conventions 9 (no forced binding — the bind decision is
|
||||
the producer's), 13 (access control), 8 (two-pump contract — the
|
||||
accept dispatcher is a per-accept `pump_bidi`, not a hand-rolled
|
||||
loop over one stream)
|
||||
|
||||
## Amendment 1 (the correlation token, 2026-09-18)
|
||||
|
||||
The decision stands — bind-first establisher, accept-as-open, the
|
||||
`bound` reply field, the forwarded op. What changes: the forwarded
|
||||
op's correlation key (§3's `channel` param) and the bind-first
|
||||
establisher's reply surface (§2 gains `listen`).
|
||||
|
||||
- **The `channel`-as-listener-channel-ID key is superseded by a
|
||||
producer-minted token.** Two facts forced the change, both found
|
||||
while decomposing the implementation against the landed alkcall
|
||||
0.8.0 surface:
|
||||
1. **The serving side never learns its own channel ID.** The
|
||||
open-op wrapper allocates the channel and replies with its ID,
|
||||
but the `OpenHandler`/establisher surface is
|
||||
`Fn(Value, Option<ChannelPlan>, Connection, AuthContext)` — no
|
||||
ID anywhere. The §3 semantic ("the opener knows … without a
|
||||
registry op") required the producer's accept dispatcher to know
|
||||
its own listen channel's ID to stamp into forwarded opens;
|
||||
unimplementable without a new upstream surface. (Filing one —
|
||||
exposing the allocated ID to handlers — was considered and
|
||||
rejected: it is an alkcall-surface change for a correlation
|
||||
need this crate can solve in-protocol, and it would still not
|
||||
fix (2).)
|
||||
2. **Generic hub relays do not rewrite params.** The in-tree
|
||||
`ChannelRelay` (ADR-051) forwards the open-op input untouched —
|
||||
it strips/rewrites only the reply's `channel_id`. A spoke's
|
||||
forwarded opens would carry the spoke-side listen channel's ID,
|
||||
which the end opener never allocated and cannot correlate ("hubs
|
||||
re-map the id naturally" assumed tunnel-specific relay behavior
|
||||
the generic machinery deliberately does not have).
|
||||
- **The token:** the bind-first establisher mints a fresh u32 per
|
||||
open, unique on that producer; carries it in the plan (the accept
|
||||
dispatcher reads it) and contributes it as the additive `listen`
|
||||
reply field via the same `Establishment::with_reply_field` carrier
|
||||
`bound` rides. Both fields pass through relays untouched
|
||||
(reply fields ride per-hop-truthful, ADR-051), and the forwarded
|
||||
open's `listen` param carries the minted token at every hop — the
|
||||
end opener correlates on the value from its own open's reply.
|
||||
- **Why not a consumer-minted token:** the opener choosing the token
|
||||
and passing it in the listen open's params would work on the direct
|
||||
path, but fails hub re-produce the same way (the hub's own
|
||||
bind-first open is a NEW open — it cannot adopt the end opener's
|
||||
token without a pass-through mechanism, which is exactly the
|
||||
params-rewriting relays don't do). Producer-minted is the direction
|
||||
information already flows (producer → opener via reply fields);
|
||||
no new flow is needed.
|
||||
- **Token lifecycle:** minted at establisher time (one per open —
|
||||
two opens never share a token, so the opener's correlation map
|
||||
needs no session disambiguation beyond the token); retired with
|
||||
the listener session (the token's only consumer is the per-accept
|
||||
forwarded opens of that listener). Uniqueness is per-producer;
|
||||
the opener-side map keys on the token as read from its own reply.
|
||||
- **Scope names, op ids, `bound`, `peer` — unchanged.** The wire
|
||||
delta vs the pre-amendment ADR is exactly: the reply gains the
|
||||
optional `listen` field, the forwarded params rename `channel` →
|
||||
`listen` with token semantics. Nothing else shipped against either
|
||||
(no consumer exists), so the change is pre-consumer and legal.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: implemented
|
||||
last_updated: 2026-09-10
|
||||
status: draft
|
||||
last_updated: 2026-09-18 (OQ-TN-16 updated — ADR-008 Amendment 1: the correlation token)
|
||||
---
|
||||
|
||||
# alktunnels — Open Questions
|
||||
@@ -184,16 +184,23 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
### OQ-TN-12: Hub re-produce composition helper
|
||||
|
||||
- **Origin**: OQ-TN-05's remaining thread (2026-09-05 hub model)
|
||||
- **Status**: deferred(scope)
|
||||
- **Status**: resolved (upstream, 2026-09-18) — alkcall 0.8.0 ships
|
||||
the in-tree `ChannelRelay` + `HubLegTemplate`/`HubLegImports`
|
||||
(alkcall ADR-051, review 008 Unit 3): the relay openable wraps
|
||||
from_call-imported marked specs on the consumer-leg registry via
|
||||
the full open-op wrapper (ACL, cap/ledger, establishment bound,
|
||||
teardown), the translate hop calls the producer leg with
|
||||
`forwarded_for` from the consumer's per-call identity, the
|
||||
byte-forward hop is `pump_bidi` awaited inline, the spoke reply's
|
||||
`channel_id` is stripped/replaced (other fields ride through —
|
||||
`bound` flows end-to-end per-hop truthful), and the adopted
|
||||
producer-leg entry reclaims via a `Drop` guard on every plan path.
|
||||
The composition helper a hub proxy needed is upstream — this crate
|
||||
composes it (OQ-TN-05's resolution stands: no tunnel-side trait).
|
||||
Tracker task: `architecture/oq-tn-12-tracker` (tasks/architecture/).
|
||||
- **Priority**: low
|
||||
- **Blocked on**: a concrete hub-proxy consumer (a crate that
|
||||
re-produces a consumed tunnel resource) — the reverse POC's hub is
|
||||
the seed shape (consumer + `register_openable` on the same
|
||||
connection) but does not exercise re-export; if one materializes,
|
||||
decide whether a composition helper belongs in this crate or is
|
||||
assembly-layer wiring of the existing surface. Tracker task:
|
||||
`architecture/oq-tn-12-tracker` (tasks/architecture/).
|
||||
- **Cross-references**: OQ-TN-05, ADR-004, alkcall ADR-042 (relay)
|
||||
- **Cross-references**: OQ-TN-05, ADR-004, alkcall ADR-042 (relay as
|
||||
amended by ADR-051), ADR-008 §6 (the hub policy sentence)
|
||||
|
||||
### OQ-TN-13: UDP truncation semantics at the codec boundary
|
||||
|
||||
@@ -240,4 +247,64 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
the wire enum's `unix` value now has a v1 implementation path.
|
||||
- **Cross-references**: ADR-003, ADR-004, overview.md; alktty
|
||||
`tty-local.md` §pipe mode, alktty ADR-004 (exit code on a control
|
||||
chunk)
|
||||
chunk)
|
||||
|
||||
## Theme 5: The graduation (alksocks consumer, 2026-09-15/16)
|
||||
|
||||
### OQ-TN-15: The direct op (dynamic-target egress)
|
||||
|
||||
- **Origin**: `docs/research/tunnels-graduation.md` §2 (the alksocks
|
||||
graduation discussion, 2026-09-15)
|
||||
- **Priority**: high
|
||||
- **Status**: resolved — [ADR-007](decisions/007-direct-open-op.md)
|
||||
(2026-09-16). Option (a): a second `Sub`-typed open op
|
||||
`channels/tunnel/direct` on the same `alk/tunnel` ALPN, params
|
||||
`{substrate, target}` (self-describing JSON target keyed by the
|
||||
sibling substrate — ATYP bytes rejected: not schema-describable, no
|
||||
unix path form), scope `tunnel:direct` (never implied by
|
||||
`tunnel:open` — structural ACL separation), dial-only (no
|
||||
listen/direct-BIND form — ingress is the listen + forwarded
|
||||
surface). The op-id convention needed an upstream alkcall amendment
|
||||
(ADR-047's discovery derivation was `/sub`-shaped) — filed in
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
|
||||
(ask #1) and **landed in alkcall 0.8.0** (ADR-047 amendment 3:
|
||||
flavor-form op ids + the explicit `channel_open_alpn` discovery
|
||||
field; hub relay reconstructs the marker).
|
||||
- **Cross-references**: ADR-001 (per-op amendment), ADR-002
|
||||
Amendment 1, ADR-006 Amendment 1, wire.md, producer.md
|
||||
|
||||
### OQ-TN-16: Listen metadata (bound-addr discovery + peer fidelity)
|
||||
|
||||
- **Origin**: `docs/research/tunnels-graduation.md` §3 (the alksocks
|
||||
BIND POC's two asks: bound-addr without consuming an accept; peer
|
||||
metadata on accepted handles)
|
||||
- **Priority**: high
|
||||
- **Status**: resolved — [ADR-008](decisions/008-listen-metadata-forwarded-op.md)
|
||||
(2026-09-16; **amended 2026-09-18** — Amendment 1). The
|
||||
`forwarded-tcpip` shape adopted over per-ask carriers: (1) a
|
||||
bind-first listen establisher — establishment ends at bind time, so
|
||||
the open-op reply carries the real OS-chosen address via an
|
||||
additive `bound` field (ask #1; the carrier is the establisher →
|
||||
reply projection — filed as the review's ask #2 and **landed in
|
||||
alkcall 0.8.0** as ADR-049 amendment 3,
|
||||
`Establishment::with_reply_field` + `open_channel_with_reply`),
|
||||
and the address-first-vs-accept-first BIND deadlock + the 10s
|
||||
establishment-bound problem dissolve with the shape; (2) the
|
||||
forwarded op `channels/tunnel/forwarded` (accept-as-open) — the
|
||||
producer opens a channel per accept with `{listen, substrate,
|
||||
peer}` in params (ask #2's carrier; the `TargetHandle.peer` option
|
||||
is superseded). Amendment 1: the correlation key is a
|
||||
**producer-minted token** (`listen` reply field + forwarded
|
||||
param), not the listen channel's ID — the serving-side handler
|
||||
never sees its own channel ID (alkcall 0.8.0's `OpenHandler`
|
||||
surface) and generic relays don't rewrite params, so a
|
||||
producer-minted token (which rides reply fields and params through
|
||||
hubs untouched) is the only key that works end-to-end. The
|
||||
accept-first listen shape stays for accept-queue-backed resources.
|
||||
Bonus from the same release: the in-tree `ChannelRelay` +
|
||||
`HubLegTemplate` (alkcall ADR-051) pass `bound`/`listen` through
|
||||
the hub per-hop truthful (ADR-008 §6 realized).
|
||||
- **Cross-references**: ADR-002 Amendment 1 (the flavor-form op id),
|
||||
ADR-006 Amendment 1 (`tunnel:forwarded`), ADR-005 (teardown
|
||||
ownership extended to `TunnelListener`), producer.md,
|
||||
consumer.md, wire.md
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: implemented
|
||||
last_updated: 2026-09-10
|
||||
status: draft
|
||||
last_updated: 2026-09-18 (the graduation: the direct op, listen metadata, the forwarded op — ADR-007/008 as amended: the correlation token)
|
||||
---
|
||||
|
||||
# alktunnels — Overview
|
||||
@@ -16,30 +16,39 @@ details are in the sibling documents ([wire.md](wire.md),
|
||||
|
||||
## What
|
||||
|
||||
`alktunnels` registers the `alk/tunnel` ALPN's open op on alkcall
|
||||
channels and provides the typed consumer session:
|
||||
`alktunnels` registers the `alk/tunnel` ALPN's open ops on alkcall
|
||||
channels and provides the typed consumer sessions:
|
||||
|
||||
- **The producer half** (a `channels/tunnel/sub` open op registered
|
||||
via `ChannelCore::register_openable_with_establisher`, alkcall
|
||||
ADR-047/049): the establisher validates params semantically
|
||||
(registry lookup of `{resource, substrate}` → backing), dials (or
|
||||
accepts for) the substrate, and returns the dialed handle via
|
||||
`Establishment::new(plan)`; the pump handler awaits
|
||||
- **The producer half** (open ops registered via
|
||||
`ChannelCore::register_openable_with_establisher`, alkcall
|
||||
ADR-047/049): the establisher validates params semantically, then
|
||||
dials (or binds/accepts for) the substrate, and returns the handle
|
||||
via `Establishment::new(plan)`; the pump handler awaits
|
||||
`alkcall::channels::pump_bidi` over the channel and the substrate
|
||||
halves (ADR-050, R-02's lifetime contract).
|
||||
- **The consumer half** (`TunnelSession`, ADR-005): opens tunnel
|
||||
channels (`ChannelClient::open_channel` on the forward path; the
|
||||
reverse path's call + adopt equivalent), splits the channel
|
||||
`BiStream`, presents the substrate-shaped data plane (raw halves or
|
||||
datagram codec), and owns teardown (close/join/Drop).
|
||||
halves (ADR-050, R-02's lifetime contract). Three ops on the one
|
||||
ALPN (ADR-002 Amendment 1): `channels/tunnel/sub` (the resource
|
||||
op), `channels/tunnel/direct` (dynamic-target egress, ADR-007), and
|
||||
— served by the listen-opener side — `channels/tunnel/forwarded`
|
||||
(accept-as-open, ADR-008). Three establisher shapes: dial, listen
|
||||
(accept-first), and bind-first listen.
|
||||
- **The consumer half** (`TunnelSession` / `TunnelListener`,
|
||||
ADR-005/008): opens tunnel channels (`ChannelClient::open_channel`
|
||||
on the forward path; the reverse path's call + adopt equivalent),
|
||||
splits the channel `BiStream`, presents the substrate-shaped data
|
||||
plane (raw halves or datagram codec), and owns teardown
|
||||
(close/join/Drop). `TunnelListener` is the listen-flow session
|
||||
(bound-address discovery + forwarded-connection receipt).
|
||||
|
||||
The guiding insight (the OQ-TN-01 reframe):
|
||||
|
||||
> A tunnel is a resource, not an address. `params` identify a produced
|
||||
> resource + substrate; the producer owns the backing. Rich addressing
|
||||
> (SOCKS5 ATYP, per-datagram remotes) enters only through the
|
||||
> `-D`/dynamic composition path — inside the tunnel payload, never in
|
||||
> the wire.
|
||||
> A tunnel is a resource, not an address. Base params identify a
|
||||
> produced resource + substrate; the producer owns the backing. The
|
||||
> one additive exception is the direct op (ADR-007): a consumer may
|
||||
> ask the producer to dial an arbitrary target — a different
|
||||
> capability, its own op and its own scope (`tunnel:direct`), never
|
||||
> implied by `tunnel:open`. Rich per-datagram addressing (SOCKS5 ATYP,
|
||||
> udpgw-style remotes) still enters only through the `-D`/dynamic
|
||||
> composition path — inside the tunnel payload, never in the wire.
|
||||
|
||||
## Why
|
||||
|
||||
@@ -68,12 +77,17 @@ A tunnel channel's life (full detail in [wire.md](wire.md)):
|
||||
|
||||
1. **Open (the establishment phase).** The consumer calls
|
||||
`channels/tunnel/sub` with `params = {resource, substrate}`
|
||||
(ADR-001). The producer's establisher — the open op's awaited
|
||||
phase (alkcall ADR-049) — resolves the resource, dials the
|
||||
(ADR-001) — or `channels/tunnel/direct` with
|
||||
`params = {substrate, target}` (ADR-007, dynamic egress). The
|
||||
producer's establisher — the open op's awaited phase (alkcall
|
||||
ADR-049) — resolves the resource (or parses the target), dials the
|
||||
substrate, and returns the handle in the plan. Failure is a typed
|
||||
`channel:open_failed` (`dial_failed` / `unknown_resource` /
|
||||
`resource_shortage` / `handler_error` / `timeout`); the channel
|
||||
never exists on the opener's side afterward.
|
||||
never exists on the opener's side afterward. A bind-first listen
|
||||
open completes at bind time and may carry `"bound"` (the resolved
|
||||
address) and `"listen"` (the correlation token) in the reply
|
||||
(ADR-008 §2/Amendment 1).
|
||||
2. **Pump (the data plane).** Two pumps, one per direction, via
|
||||
`pump_bidi` (ADR-050): each shuts the opposite sink down on
|
||||
completion; half-close semantics fall out. Stream substrates ride
|
||||
@@ -88,14 +102,17 @@ Direction is not in the protocol: whoever can reach the target is the
|
||||
producer; whoever wants the bytes is the consumer; connection
|
||||
direction (who dialed the transport) is independent of tunnel
|
||||
direction (OQ-TN-03's hub model). `-L`, `-R`, and `-D` are assembly
|
||||
shapes over the same open op.
|
||||
shapes over the same ops; the `-R` composed flow rides the bind-first
|
||||
establisher + the forwarded op (ADR-008).
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
alktunnels (default — wasm-clean)
|
||||
├── alkcall 0.7.0 (core types, channels: ChannelCore/ChannelClient/manager,
|
||||
│ pump_bidi, ServingConfig identity seam — CF-005/006)
|
||||
├── alkcall 0.8.0 (core types, channels: ChannelCore/ChannelClient/manager,
|
||||
│ pump_bidi, ServingConfig identity seam — CF-005/006,
|
||||
│ establisher reply projection + flavor-form discovery
|
||||
│ derivation + ChannelRelay/HubLegTemplate — review 008/ADR-051)
|
||||
├── tokio (wasm-clean subset: rt, sync, io-util, macros, time)
|
||||
├── bytes, futures, serde/serde_json, thiserror, tracing, async-trait
|
||||
└── (no backend deps — sockets/process live in feature-gated modules)
|
||||
@@ -122,14 +139,19 @@ trivial here, since the codec is a 2-byte length prefix).
|
||||
```
|
||||
src/
|
||||
lib.rs — re-exports; crate docs
|
||||
params.rs — TunnelParams {resource, substrate} + open-op input schema (ADR-001)
|
||||
params.rs — TunnelParams {resource, substrate} + open-op input schema (ADR-001);
|
||||
TunnelTarget {substrate, target} + direct-op schema (ADR-007);
|
||||
TunnelForwardedParams {listen, substrate, peer} (ADR-008 as amended);
|
||||
the three scope constants
|
||||
wire.rs — the data-plane codec: frame_datagram / DatagramReader (ADR-003)
|
||||
producer.rs — the open op: tunnel_open_spec (scope-gated), establisher
|
||||
shapes (dial + listen), pump handler (pump_bidi),
|
||||
register_tunnel_openable (ADR-004 shapes)
|
||||
consumer.rs — TunnelSession: open / adopt / stream_halves /
|
||||
take_halves / send_datagram /
|
||||
recv_datagram / pump_against / close / join / Drop (ADR-005)
|
||||
producer.rs — the open ops: tunnel_open_spec + direct/forwarded spec builders
|
||||
(scope-gated), establisher shapes (dial + listen + bind-first),
|
||||
pump handler (pump_bidi / accept dispatcher),
|
||||
register_tunnel_*_openable (ADR-004/007/008 shapes)
|
||||
consumer.rs — TunnelSession: open / open_direct / adopt / stream_halves /
|
||||
take_halves / send_datagram / recv_datagram / pump_against /
|
||||
close / join / Drop (ADR-005); TunnelListener: open / bound /
|
||||
forwarded-receipt / close / Drop (ADR-008)
|
||||
error.rs — TunnelError + the typed open-error surface (ADR-049 §4)
|
||||
local/ — the `local` feature module: real socket halves functions
|
||||
```
|
||||
@@ -147,7 +169,9 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
||||
| [003](decisions/003-codec-and-udp-framing.md) | Data-plane codec | Raw pass-through (stream) / mandatory `[len: u16 BE]` (UDP — F-2 mandate); `len=0` is a legal empty datagram |
|
||||
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend trait | Halves functions at the assembly layer; listen is an establisher shape; trait re-evaluated at the 3-consumer threshold |
|
||||
| [005](decisions/005-consumer-session-owns-teardown.md) | Consumer session owns teardown | `TunnelSession` with close/join/Drop — the W3 gap closes structurally |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-control posture | The open gate is the boundary: `tunnel:open` scope (stable once published), op-level ACL, ownership seam, no allowlists in v1 |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-control posture | The open gate is the boundary: `tunnel:open` scope (stable once published), op-level ACL, ownership seam, no allowlists in v1; amended — `tunnel:direct`/`tunnel:forwarded` scopes + the hub re-produce policy sentence |
|
||||
| [007](decisions/007-direct-open-op.md) | The direct open op | `channels/tunnel/direct`: dynamic-target egress, `{substrate, target}` params, `tunnel:direct` scope, dial-only — the ssh `direct-tcpip` capability with structural ACL separation |
|
||||
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen metadata + the forwarded op | Bind-first establisher (reply `bound` field = BND.ADDR fidelity; `listen` = the correlation token), `channels/tunnel/forwarded` accept-as-open (params carry `{listen, substrate, peer}` = reply#2 fidelity) — the `forwarded-tcpip` shape; amended — producer-minted correlation token supersedes the channel-ID key |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -155,10 +179,13 @@ Open questions are tracked in [open-questions.md](open-questions.md).
|
||||
Key questions affecting this document:
|
||||
|
||||
- **OQ-TN-11**: resource naming collision domain + lifecycle (partially resolved)
|
||||
- **OQ-TN-12**: hub re-produce composition helper (deferred(scope))
|
||||
- **OQ-TN-12**: hub re-produce composition helper (deferred(scope) —
|
||||
the policy sentence is pinned: the hub's ACL governs the final dial)
|
||||
- **OQ-TN-13**: UDP truncation semantics (resolved — fail-loud, ADR-003)
|
||||
- **OQ-TN-14**: unix/stdio placement (resolved — unix in via
|
||||
`local`, stdio out: alktty's pipe mode owns process stdio)
|
||||
- **OQ-TN-15**: the direct op (resolved — ADR-007)
|
||||
- **OQ-TN-16**: listen metadata (resolved — ADR-008)
|
||||
|
||||
## References
|
||||
|
||||
|
||||
+193
-22
@@ -1,22 +1,28 @@
|
||||
---
|
||||
status: implemented
|
||||
last_updated: 2026-09-10
|
||||
status: draft
|
||||
last_updated: 2026-09-18 (the direct op, the bind-first establisher, the forwarded op — ADR-007/008 as amended: the correlation token)
|
||||
---
|
||||
|
||||
# alktunnels — Producer Half
|
||||
|
||||
The producer half: the `channels/tunnel/sub` open op — its spec, its
|
||||
establisher (dial + listen shapes), its pump handler, and its
|
||||
registration. The producer is whoever can reach the tunnel target;
|
||||
connection direction is irrelevant (OQ-TN-03's hub model). The reverse
|
||||
POC validated the producer running on the connect side of its own
|
||||
connection (`from_connection_with_serving`).
|
||||
establishers (dial + listen shapes), its pump handler, and its
|
||||
registration — plus the sibling ops on the same ALPN: the direct op
|
||||
(`channels/tunnel/direct`, dynamic-target egress) and the forwarded op
|
||||
(`channels/tunnel/forwarded`, accept-as-open). The producer is whoever
|
||||
can reach the tunnel target; connection direction is irrelevant
|
||||
(OQ-TN-03's hub model). The reverse POC validated the producer running
|
||||
on the connect side of its own connection
|
||||
(`from_connection_with_serving`).
|
||||
|
||||
Design decisions: [ADR-001](decisions/001-open-params-layout.md)
|
||||
(params), [ADR-002](decisions/002-alpn-strategy.md) (ALPN),
|
||||
[ADR-003](decisions/003-codec-and-udp-framing.md) (codec),
|
||||
[ADR-004](decisions/004-no-backend-trait.md) (no backend trait). This
|
||||
document is the normative WHAT.
|
||||
[ADR-004](decisions/004-no-backend-trait.md) (no backend trait),
|
||||
[ADR-007](decisions/007-direct-open-op.md) (the direct op),
|
||||
[ADR-008](decisions/008-listen-metadata-forwarded-op.md) (listen
|
||||
metadata, bind-first, the forwarded op). This document is the
|
||||
normative WHAT.
|
||||
|
||||
## The Open-Op Spec
|
||||
|
||||
@@ -50,6 +56,35 @@ posture — the reverse POC's W2): the dispatcher reads through the
|
||||
shared `Arc<OperationRegistry>` per dispatch. Late registration is a
|
||||
supported assembly shape, not a race.
|
||||
|
||||
## The Direct Op (`channels/tunnel/direct`)
|
||||
|
||||
Dynamic-target egress (ADR-007): the same ALPN, a second `Sub`-typed
|
||||
open op whose establisher dials a per-call target instead of resolving
|
||||
a resource. One ALPN, two ops, two scopes — dial-what-the-producer-
|
||||
registered (`tunnel:open`) and dial-anything (`tunnel:direct`) are
|
||||
different capabilities.
|
||||
|
||||
```text
|
||||
operation id: channels/tunnel/direct
|
||||
op type: Sub (same reply/wrapper shape as …/sub)
|
||||
ALPN marker: alk/tunnel (ChannelOpenSpec)
|
||||
visibility: External
|
||||
input schema: { substrate: enum [tcp, udp, unix] (required),
|
||||
target: object, per-substrate sub-schema (required) }
|
||||
output schema: { channel_id: integer > 0 }
|
||||
access control: required_scopes: ["tunnel:direct"]
|
||||
description: SHOULD carry a human-readable hint (dynamic-target egress)
|
||||
```
|
||||
|
||||
- **No registry lookup** — the establisher parses `target` against
|
||||
`substrate` (malformed → `invalid_input`-class; `unknown_resource`
|
||||
can never fire) and injects the parsed `TunnelTarget` into the same
|
||||
assembly-provided `DialFn` the base op's backings resolve to.
|
||||
- **Dial-only** — no listen/direct-BIND form; the ingress capability
|
||||
is the listen + forwarded surface (ADR-008).
|
||||
- Registration: `register_tunnel_direct_openable` (same wrapper, same
|
||||
plan-flow; the spec builder is public for assembly variants).
|
||||
|
||||
## The Establisher
|
||||
|
||||
The establisher is the open op's awaited, bounded establishment phase
|
||||
@@ -62,7 +97,7 @@ reverse POC F-1, documented upstream). On failure:
|
||||
`EstablishmentError` (the typed reason codes) → the wrapper tears the
|
||||
channel down and replies `channel:open_failed`.
|
||||
|
||||
**Two shapes** (both return the same plan-flow; ADR-004):
|
||||
**Three shapes** (all return the same plan-flow; ADR-004):
|
||||
|
||||
### 1. Dial establisher (the `-L`/forward shape)
|
||||
|
||||
@@ -79,6 +114,10 @@ never sees a socket type. Failure mapping:
|
||||
| establisher-internal failure | `handler_error` |
|
||||
| the bound exceeded | `timeout` (wrapper-level) |
|
||||
|
||||
The direct establisher is this shape minus the lookup row: target
|
||||
malformed for the substrate is the schema/`invalid_input`-class
|
||||
failure (ADR-007 §4's table).
|
||||
|
||||
### 2. Listen establisher (the producer-side listener variant)
|
||||
|
||||
For a producer whose resource is a LISTENER (SSH `-R` far-side
|
||||
@@ -91,6 +130,58 @@ mapping: an empty queue during establishment is `resource_shortage`;
|
||||
a closed listener is `dial_failed`-class. No new wire surface — the
|
||||
listen variant is plan-flow with a different halves source.
|
||||
|
||||
**The accept-first shape stays** (ADR-008 §5): for producers whose
|
||||
resource backing IS an accept queue (an externally-fed or pre-bound
|
||||
listener shared across opens), this remains the correct shape — the
|
||||
POC #5 composed-BIND shape. Its limits (no `bound` in the reply — the
|
||||
accept hasn't happened at establishment; the POC's
|
||||
establishment-bound-vs-wait problem) are inherent to accepting-first.
|
||||
|
||||
### 3. Bind-first listen establisher (the `-R`/ingress shape, ADR-008)
|
||||
|
||||
For a producer that OWNS the bind decision (the common hub case;
|
||||
alksocks' composed BIND): the establisher **binds the listener** (via
|
||||
the assembly-provided listen function — the protocol never binds,
|
||||
OQ-TN-04) and returns the listener handle as the plan payload.
|
||||
Establishment is fast — bounded by the bind, not by any app-server
|
||||
dial:
|
||||
|
||||
- **The establishment phase ends at bind time**, so the open-op reply
|
||||
can carry the REAL OS-chosen address: the establisher contributes
|
||||
`"bound": {host, port}` (or `{path}`) to the reply — the
|
||||
establisher → reply projection (ADR-049 amendment 3,
|
||||
`Establishment::with_reply_field`; the `channel_id` key is
|
||||
wrapper-reserved). **Landed in alkcall 0.8.0** (review 008 U-2);
|
||||
consumers read it via `ChannelClient::open_channel_with_reply`.
|
||||
- **The establisher mints the correlation token** (ADR-008
|
||||
Amendment 1): a fresh u32 per open, contributed as the `"listen"`
|
||||
reply field (the same projection) and carried in the plan — the
|
||||
accept dispatcher reads it from the plan and stamps it into each
|
||||
forwarded open's `listen` param. The plan carries
|
||||
`{listener, token, substrate}` (a bind-first plan type; the
|
||||
substrate is stamped at establisher time — the dispatcher reads it
|
||||
from the plan, never re-parses wire input); the token is
|
||||
relay-transparent by construction (reply fields and params both
|
||||
pass hubs untouched — alkcall ADR-051; ADR-008 Amendment 1 has the
|
||||
why-superseded of the channel-ID key).
|
||||
- **The pump handler for this shape is an accept dispatcher**, not
|
||||
`pump_bidi`: it accepts the channel's `BiStream` (the R-02
|
||||
contract), then runs the assembly-provided accept loop — each
|
||||
accepted connection produces a forwarded-channel open toward the
|
||||
listen opener (§The Forwarded Op below); the accepted halves pump
|
||||
against the adopted channel. The listen channel's own `BiStream`
|
||||
stays idle; its EOF (the opener dropping the listener session)
|
||||
cancels the loop and tears the listener down.
|
||||
- Error mapping: unbindable/held port → `dial_failed`-class (the
|
||||
bind IS the dial here); bind resource exhaustion →
|
||||
`resource_shortage`; the rest of the table unchanged.
|
||||
- The address-first-vs-accept-first BIND deadlock (the alksocks POC's
|
||||
one real discovery) DISSOLVES in this shape: the open completes at
|
||||
bind time, reply#1-class metadata (the `bound` field) precedes any
|
||||
app-server dial, and the accept wait lives in the pump phase —
|
||||
teardown-governed, not establishment-bounded (the 10s bound problem
|
||||
dissolves with it).
|
||||
|
||||
**Identity:** the establisher receives the **per-call opener
|
||||
identity** (the dispatch-resolved identity overlaid onto the
|
||||
install-time context — alkcall 0.7.0 CF-006). On hub-forwarded opens
|
||||
@@ -127,11 +218,49 @@ JoinHandle<()>`):
|
||||
at runtime. Hand-rolling the two-pump shape is out (ADR-050).
|
||||
4. UDP: the plan payload is the FRAMED adapter (ADR-003) — the pump
|
||||
is unchanged; the codec lives at the substrate/pump boundary.
|
||||
5. **Bind-first listen handler (the accept dispatcher, ADR-008 §1):**
|
||||
the plan payload is the listener; after `accept_bi()`, the handler
|
||||
runs the accept loop (assembly-provided) concurrently with holding
|
||||
the channel's `BiStream` — each accepted connection produces a
|
||||
forwarded-channel open toward the listen opener. The listen
|
||||
channel's own stream stays idle (no pumps on it); its EOF cancels
|
||||
the loop (the listener tears down with the session). Per-accept
|
||||
data planes are still `pump_bidi` (convention 8 — per-channel,
|
||||
never a hand-rolled loop over one stream).
|
||||
|
||||
**The handler is substrate-agnostic by construction:** it cannot know
|
||||
whether the halves came from TCP, UDP, a unix socket, or an
|
||||
in-process pipe, and must not (AGENTS.md convention 7).
|
||||
|
||||
## The Forwarded Op (opener side, ADR-008 §3)
|
||||
|
||||
Accept-as-open: when the bind-first listener accepts, the PRODUCER
|
||||
opens a channel back toward the listen opener. The opener SERVES the
|
||||
op (`channels/tunnel/forwarded`, scope `tunnel:forwarded` — ingress;
|
||||
no establisher, the data plane starts immediately):
|
||||
|
||||
1. The producer's per-accept open carries `{listen, substrate, peer}`
|
||||
— the correlation token from the plan (the value the establisher
|
||||
minted; the opener matches it against the `listen` field of its
|
||||
open listener sessions — ADR-008 Amendment 1), the substrate
|
||||
(also from the plan — stamped at establisher time from the one
|
||||
successful params parse; the dispatcher never re-parses wire
|
||||
input), the accepted connection's remote address (reply#2-class
|
||||
fidelity's carrier).
|
||||
2. The opener's registry allocates the `channel_id`; the producer
|
||||
adopts it (`TunnelSession::adopt`) and pumps the accepted halves
|
||||
against it (`pump_against`).
|
||||
3. The opener-side handler correlates the open (via `listen`) to the
|
||||
waiting session (assembly-side bookkeeping — the protocol provides
|
||||
the op and params, not the wait state machine), accepts the
|
||||
`BiStream`, and pumps against the local session's halves.
|
||||
4. Identity: the forwarded op's opener-side gate sees the PRODUCER's
|
||||
per-call identity (CF-005 precedence — the producer may carry an
|
||||
`auth_token` when it is itself forwarding); the peer address in
|
||||
params is producer-asserted data, not transport truth — the ACL
|
||||
trusts the producer's grant, the application treats `peer` as
|
||||
metadata.
|
||||
|
||||
## Registration API (the spec surface)
|
||||
|
||||
```rust
|
||||
@@ -146,6 +275,26 @@ pub fn register_tunnel_openable(
|
||||
auth: AuthContext, // the install-time context (per-call overlay is upstream's)
|
||||
dial: DialFn, // substrate halves function (ADR-004; `local` feature or assembly)
|
||||
) -> Result<(), String>
|
||||
|
||||
pub fn register_tunnel_direct_openable(
|
||||
core: &ChannelCore,
|
||||
on_registry: &Arc<OperationRegistry>,
|
||||
auth: AuthContext,
|
||||
dial: DialFn, // the SAME DialFn the base op uses; receives the parsed TunnelTarget
|
||||
) -> Result<(), String>
|
||||
|
||||
pub fn register_tunnel_listen_openable( // accept-first (shape 2, unchanged)
|
||||
... // as today: listen_establisher + AcceptQueue
|
||||
)
|
||||
|
||||
pub fn register_tunnel_bindfirst_openable( // bind-first (shape 3, ADR-008)
|
||||
core: &ChannelCore,
|
||||
on_registry: &Arc<OperationRegistry>,
|
||||
auth: AuthContext,
|
||||
bind: BindFn, // substrate bind function -> (listener, bound addr)
|
||||
accept: AcceptFn, // per-accept: open the forwarded channel, pump
|
||||
) -> Result<(), String>
|
||||
```
|
||||
```
|
||||
|
||||
- `ResourceRegistry` (assembly construct, OQ-TN-11): maps
|
||||
@@ -153,24 +302,36 @@ pub fn register_tunnel_openable(
|
||||
candidate; the collision domain is the producer's registry
|
||||
(ADR-001 — two producers on one connection may expose the same
|
||||
resource name independently).
|
||||
- The scope constant `TUNNEL_OPEN_SCOPE = "tunnel:open"`; the open-op
|
||||
spec builder is public so assembly layers can compose variants
|
||||
(e.g. extra ACL fields) without forking the wire shape.
|
||||
- The scope constants `TUNNEL_OPEN_SCOPE = "tunnel:open"`,
|
||||
`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`, and
|
||||
`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"` (ADR-006 Amendment 1);
|
||||
the open-op spec builders are public so assembly layers can compose
|
||||
variants (e.g. extra ACL fields) without forking the wire shape.
|
||||
- The pump handler factory is public for the same reason.
|
||||
|
||||
## Access control posture
|
||||
|
||||
Tunnels reach local networks — the open gate is the security boundary
|
||||
(OQ-TN-08):
|
||||
(OQ-TN-08, ADR-006 + Amendment 1):
|
||||
|
||||
- The registry runs `AccessControl` before the establisher (the ACL
|
||||
rides `register_openable`; free via alkcall).
|
||||
- `TUNNEL_OPEN_SCOPE` is the base gate; the assembly layer MAY add
|
||||
resource-scoped checks via the establisher (ownership on the
|
||||
resource name) — the protocol provides the seam, not the policy.
|
||||
- No target allowlists, no tunnel-specific policy machinery in v1 —
|
||||
the far side owns the resource (OQ-TN-08 resolution); discovery is
|
||||
the ACL-filtered ops listing.
|
||||
- `TUNNEL_OPEN_SCOPE` is the base gate; `TUNNEL_DIRECT_SCOPE` gates
|
||||
dynamic-target egress (never implied by `tunnel:open`);
|
||||
`TUNNEL_FORWARDED_SCOPE` gates ingress (receiving forwarded
|
||||
connections) on the opener side. Three scopes, three capabilities.
|
||||
- The assembly layer MAY add resource-scoped checks via the
|
||||
establisher (ownership on the resource name) — the protocol
|
||||
provides the seam, not the policy. For the direct op the seam is
|
||||
the parsed `TunnelTarget` (a deployment MAY enforce target
|
||||
allowlists in its establisher — policy, not protocol).
|
||||
- No target allowlists in the PROTOCOL, no tunnel-specific policy
|
||||
machinery in v1 — the far side owns the resource (OQ-TN-08
|
||||
resolution); discovery is the ACL-filtered ops listing.
|
||||
- **Hub re-produce policy (the security model sentence, ADR-006
|
||||
Amendment 1):** a hub re-producing the direct op applies ITS OWN
|
||||
target policy at the re-produce hop — the hub's ACL governs the
|
||||
final dial. The hub is the network boundary.
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -179,12 +340,22 @@ Tunnels reach local networks — the open gate is the security boundary
|
||||
candidate)
|
||||
- **OQ-TN-14**: resolved (split: unix in, stdio out — alktty's pipe
|
||||
mode owns process stdio; see open-questions.md)
|
||||
- **OQ-TN-15/16**: resolved (ADR-007/008 — the direct op, listen
|
||||
metadata, the forwarded op)
|
||||
|
||||
## References
|
||||
|
||||
- [wire.md](wire.md) (the open op's wire surface),
|
||||
- [wire.md](wire.md) (the ops' wire surface),
|
||||
[overview.md](overview.md) (module map)
|
||||
- Forward POC `docs/research/poc-summary.md`; reverse POC
|
||||
`docs/research/reverse-poc-summary.md` (both producer shapes ran)
|
||||
- `docs/research/tunnels-graduation.md` (the graduation research;
|
||||
§3.1 is the shape bind-first + the forwarded op adopt)
|
||||
- alkcall ADR-047/049 (open ops + establishment), ADR-050 (`pump_bidi`),
|
||||
ledger CF-005/CF-006 (identity precedence + per-call opener)
|
||||
ledger CF-005/CF-006 (identity precedence + per-call opener)
|
||||
- Upstream asks (review 008): `alkcall/docs/reviews/
|
||||
008-tunnels-graduation-upstream-review.md` — both landed in
|
||||
alkcall 0.8.0 (flavor-form discovery derivation, ADR-047
|
||||
amendment 3; the establisher → reply `bound` projection, ADR-049
|
||||
amendment 3), plus the in-tree `ChannelRelay`/`HubLegTemplate`
|
||||
(alkcall ADR-051)
|
||||
+156
-7
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: implemented
|
||||
last_updated: 2026-09-10
|
||||
status: draft
|
||||
last_updated: 2026-09-20 (review 002 D-1/D-2 sync: the params schemas' port bounds, `additionalProperties: false` gates, `listen` `minimum: 1`, and the registry-side gate ordering recorded; ADR-007's oneOf wording corrected to the shipped allOf/if-then)
|
||||
---
|
||||
|
||||
# alktunnels — Wire Format
|
||||
@@ -13,7 +13,8 @@ its own 8-byte header transparently (alknet ADR-093); the tunnel
|
||||
protocol owns everything inside the `BiStream`.
|
||||
|
||||
Design decisions live in the ADRs ([001](decisions/001-open-params-layout.md),
|
||||
[002](decisions/002-alpn-strategy.md), [003](decisions/003-codec-and-udp-framing.md));
|
||||
[002](decisions/002-alpn-strategy.md), [003](decisions/003-codec-and-udp-framing.md),
|
||||
[007](decisions/007-direct-open-op.md), [008](decisions/008-listen-metadata-forwarded-op.md));
|
||||
this document is the normative WHAT.
|
||||
|
||||
## The Open Op (`channels/tunnel/sub`)
|
||||
@@ -49,6 +50,8 @@ this document is the normative WHAT.
|
||||
|
||||
- Success: `{ "channel_id": <u32> }` — the channel is open, the
|
||||
establishment phase (dial) succeeded, the pump handler is running.
|
||||
Bind-first listen opens MAY add `"bound"` (§The Listen Open Op's
|
||||
Reply below).
|
||||
- Failure: a typed `channel:open_failed` call error with
|
||||
`details: { reason, message }` — `reason ∈ dial_failed` /
|
||||
`unknown_resource` / `resource_shortage` / `handler_error` /
|
||||
@@ -64,6 +67,140 @@ this document is the normative WHAT.
|
||||
the establisher and pump handler receive the per-call opener
|
||||
identity (CF-006).
|
||||
|
||||
## The Direct Op (`channels/tunnel/direct`)
|
||||
|
||||
Dynamic-target egress (ADR-007) — "dial what I name," not "dial what
|
||||
the producer registered." `Sub`-typed, ALPN marker `alk/tunnel`; the
|
||||
same establishment phase and wrapper as `…/sub`. Scope gate:
|
||||
`["tunnel:direct"]` (`TUNNEL_DIRECT_SCOPE`) — **never implied by
|
||||
`tunnel:open`** (ADR-006 Amendment 1).
|
||||
|
||||
### Input (params)
|
||||
|
||||
```json
|
||||
{
|
||||
"substrate": "tcp",
|
||||
"target": { "host": "db.internal", "port": 5432 }
|
||||
}
|
||||
```
|
||||
|
||||
- `substrate`: string, required, enum `["tcp", "udp", "unix"]` — the
|
||||
same discriminator semantics as `…/sub` (ADR-001); selects the
|
||||
data-plane framing.
|
||||
- `target`: object, required, keyed by the sibling `substrate` —
|
||||
- `tcp`/`udp` → `{ "host": <string>, "port": <u16> }` (domain-form
|
||||
hosts stay unresolved on the wire — producer-side resolution, the
|
||||
ssh `-D` semantic);
|
||||
- `unix` → `{ "path": <string> }`.
|
||||
- `input_schema` pins both required; the target sub-schema is
|
||||
per-substrate — an `allOf` of two `if`/`then` branches keyed on the
|
||||
sibling `substrate` value, validated **registry-side** (the schema
|
||||
gate runs before the establisher — the behavior
|
||||
`malformed_target_is_schema_class_and_leaves_no_channel` pins; the
|
||||
establisher's serde parse re-checks as the backstop). Malformed
|
||||
targets are the `invalid_input`-class failure; **`unknown_resource`
|
||||
can never fire on the direct op** (no registry lookup).
|
||||
- Schema bounds (wire-stable — the target sub-schema is part of the
|
||||
one-way door, ADR-007 §"Wire-stable"): each branch's target object
|
||||
carries `"type": "object"` and `"additionalProperties": false` —
|
||||
- `tcp`/`udp` → `{ "host": {"type": "string"}, "port": {"type":
|
||||
"integer", "minimum": 0, "maximum": 65535} }`, both required;
|
||||
- `unix` → `{ "path": {"type": "string"} }`, required.
|
||||
|
||||
The `additionalProperties: false` gate is what makes a cross-shape
|
||||
target (a `unix` open carrying `host`) fail the registry's
|
||||
`INVALID_INPUT` class *before* the establisher — not a serde-level
|
||||
quirk.
|
||||
|
||||
### Reply / errors
|
||||
|
||||
- Success: `{ "channel_id": <u32> }` — identical semantics to
|
||||
`…/sub`; no `bound` field (dial establishers do not bind).
|
||||
- Failure: the same typed surface as `…/sub` minus `unknown_resource`,
|
||||
plus the schema/`invalid_input` class for malformed targets.
|
||||
|
||||
## The Listen Open Op's Reply — the `bound` and `listen` Fields
|
||||
|
||||
A bind-first listen open (ADR-008 §1, served by `…/sub` with the
|
||||
bind-first establisher) MAY add optional, additive reply fields:
|
||||
|
||||
```json
|
||||
{ "channel_id": 7, "bound": { "host": "203.0.113.9", "port": 42113 },
|
||||
"listen": 918273 }
|
||||
```
|
||||
|
||||
- `bound` is substrate-shaped like the direct op's target
|
||||
(`{host, port}` / `{path}`); the REAL OS-chosen address (port `0`
|
||||
resolved).
|
||||
- `listen` is the correlation token (ADR-008 Amendment 1): a u32 the
|
||||
establisher minted, unique per open on that producer — the key the
|
||||
forwarded op's params carry (§The Forwarded Op below). Riding the
|
||||
reply is what makes it relay-transparent: reply fields pass through
|
||||
hub re-produce untouched (alkcall ADR-051), and the same minted
|
||||
token reappears in every hop's reply.
|
||||
- Present only when the establisher supplies them; absent for dial
|
||||
establishers and accept-first listen opens. Old consumers ignore
|
||||
unknown reply fields (plain JSON) — wire-additive.
|
||||
- `bound` is informational surface, not addressing: a consumer MUST
|
||||
NOT treat `bound` as a dial target (the producer owns the backing —
|
||||
ADR-001). It exists for RFC-1928-reply-class fidelity (BND.ADDR)
|
||||
and observability.
|
||||
|
||||
## The Forwarded Op (`channels/tunnel/forwarded`)
|
||||
|
||||
Accept-as-open (ADR-008 §3): when a bind-first listener accepts, the
|
||||
PRODUCER opens a channel back toward the listen-opener side. The
|
||||
producer *calls* the op (the opener SERVES it — the ADR-047 §5
|
||||
connection-owner rule: the serving side allocates the `channel_id`,
|
||||
the producer-side initiator adopts). `Sub`-typed, ALPN marker
|
||||
`alk/tunnel`; no establisher (the data plane starts immediately).
|
||||
Scope gate (opener side): `["tunnel:forwarded"]`
|
||||
(`TUNNEL_FORWARDED_SCOPE`) — ingress, a separate grant from
|
||||
`tunnel:open`/`tunnel:direct` (ADR-006 Amendment 1).
|
||||
|
||||
### Input (params)
|
||||
|
||||
```json
|
||||
{
|
||||
"listen": 918273,
|
||||
"substrate": "tcp",
|
||||
"peer": { "host": "198.51.100.4", "port": 55231 }
|
||||
}
|
||||
```
|
||||
|
||||
- `listen`: integer > 0, required — the correlation token from the
|
||||
listen open's `listen` reply field (ADR-008 Amendment 1; the
|
||||
producer-minted token, relay-transparent). The opener correlates
|
||||
the accept to its waiting listener session by matching it. The
|
||||
schema pins `"minimum": 1` and the serde type pins it as the
|
||||
backstop (a `listen: 0` on the wire is a malformed open, rejected
|
||||
at parse time — never silently accepted).
|
||||
- `substrate`: string, required, enum — the listen tunnel's
|
||||
substrate; shapes the forwarded channel's data-plane framing.
|
||||
- `peer`: object, required — the accepted connection's remote address,
|
||||
substrate-shaped (`{host, port}` / `{path}`). Reply#2-class
|
||||
fidelity's carrier (ask #2).
|
||||
- Schema bounds (wire-stable, mirroring the direct op's target
|
||||
sub-schema): the per-substrate `peer` sub-schema is an `allOf` of
|
||||
two `if`/`then` branches keyed on the sibling `substrate`, each
|
||||
branch's object carrying `"type": "object"` and
|
||||
`"additionalProperties": false` —
|
||||
- `tcp`/`udp` → `{ "host": {"type": "string"}, "port": {"type":
|
||||
"integer", "minimum": 0, "maximum": 65535} }`, both required;
|
||||
- `unix` → `{ "path": {"type": "string"} }`, required.
|
||||
|
||||
Malformed peers fail the registry's schema gate before the
|
||||
opener-side handler runs.
|
||||
|
||||
### Reply / errors
|
||||
|
||||
- Success: `{ "channel_id": <u32> }` — the opener-allocated ID the
|
||||
producer adopts, then pumps the accepted halves against.
|
||||
- Failure: a producer-side accept correlation miss is the typed
|
||||
`channel:open_failed`/`handler_error`-class error; an opener without
|
||||
the scope is `FORBIDDEN`; an old opener that does not serve the op
|
||||
rejects loudly (`NOT_FOUND` — the SSH posture).
|
||||
|
||||
## The Data Plane
|
||||
|
||||
One data stream per direction on one channel — the channel ID is the
|
||||
@@ -162,16 +299,28 @@ UDP substrate, one direction's wire view:
|
||||
- **OQ-TN-13**: resolved (fail-loud, ADR-003) — see the Truncation
|
||||
bullet
|
||||
- **OQ-TN-02**: resolved — see the ADR-003 mandate
|
||||
- **OQ-TN-15**: resolved — see ADR-007 (the direct op)
|
||||
- **OQ-TN-16**: resolved — see ADR-008 (the `bound`/`listen` fields +
|
||||
the forwarded op)
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-001](decisions/001-open-params-layout.md) (params),
|
||||
[ADR-002](decisions/002-alpn-strategy.md) (ALPN),
|
||||
- [ADR-001](decisions/001-open-params-layout.md) (params, per-op
|
||||
amendment),
|
||||
[ADR-002](decisions/002-alpn-strategy.md) (ALPN + flavor-form op
|
||||
ids),
|
||||
[ADR-003](decisions/003-codec-and-udp-framing.md) (codec),
|
||||
[ADR-005](decisions/005-consumer-session-owns-teardown.md) (session
|
||||
teardown)
|
||||
teardown),
|
||||
[ADR-007](decisions/007-direct-open-op.md) (the direct op),
|
||||
[ADR-008](decisions/008-listen-metadata-forwarded-op.md) (listen
|
||||
metadata + the forwarded op)
|
||||
- Forward POC `docs/research/poc-summary.md` (codec + sentinel
|
||||
layering, 17 tests); reverse POC
|
||||
`docs/research/reverse-poc-summary.md` (F-2 mandate, W4 half-close)
|
||||
- alkcall ADR-047 (open ops), ADR-049 (establishment/typed errors),
|
||||
ADR-050 (`pump_bidi`); alknet ADR-071/093 (channels wire)
|
||||
ADR-050 (`pump_bidi`); alknet ADR-071/093 (channels wire)
|
||||
- `docs/research/tunnels-graduation.md` (the graduation research the
|
||||
new ops implement); the upstream asks (review 008) landed in
|
||||
alkcall 0.8.0 — see
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
|
||||
@@ -0,0 +1,321 @@
|
||||
---
|
||||
status: decided
|
||||
last_updated: 2026-09-16 (§2 → ADR-007; §3/§3.1 → ADR-008 — the leans are pinned, see the status note)
|
||||
---
|
||||
|
||||
# alksocks research — graduating proxy surface into alktunnels
|
||||
|
||||
**Status note (2026-09-16).** Phase 1 decided both asks; this doc is
|
||||
the research record, the ADRs are now normative:
|
||||
|
||||
- **§2 (the direct op) →
|
||||
[ADR-007](../architecture/decisions/007-direct-open-op.md)** —
|
||||
option (a) confirmed, with one refinement: the `target` encoding is
|
||||
a self-describing JSON object keyed by the sibling `substrate`
|
||||
field (`{host, port}` / `{path}`), not the ATYP byte string — the
|
||||
registry's `input_schema` validation (alkcall ADR-047) needs
|
||||
schema-describable params, and the unix path form falls out for
|
||||
free. Op id `channels/tunnel/direct`, scope `tunnel:direct`,
|
||||
dial-only.
|
||||
- **§3/§3.1 (listen metadata) →
|
||||
[ADR-008](../architecture/decisions/008-listen-metadata-forwarded-op.md)** —
|
||||
the §3.1 `forwarded-tcpip` alternative ADOPTED over the per-ask
|
||||
carriers: a bind-first listen establisher (reply carries the real
|
||||
`bound` address — ask #1; also dissolves the POC's deadlock and
|
||||
10s-bound findings) + the forwarded op `channels/tunnel/forwarded`
|
||||
with `{listen, substrate, peer}` params (ask #2; ADR-008 Amendment
|
||||
1, 2026-09-18 — the correlation key is a producer-minted token, the
|
||||
original channel-ID key was unimplementable against the landed
|
||||
handler surface). The
|
||||
`TargetHandle.peer` option (§3 ask #2, option i) is superseded.
|
||||
- **A discovery this research missed:** alkcall ADR-047 pins
|
||||
discovery's ALPN derivation to exactly `…/sub`/`…/pub` op-id shapes
|
||||
(`derive_alpn_from_op_name`) — flavor-form op ids (both new ops)
|
||||
need an upstream alkcall amendment before the first consumer. Filed:
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
|
||||
(both asks landed in alkcall 0.8.0, plus the in-tree
|
||||
`ChannelRelay`/`HubLegTemplate` — alkcall ADR-051).
|
||||
|
||||
The upstream ask ledger (§5) is now: items 1–2 pinned by ADR-007/008
|
||||
(implementation pending the two upstream alkcall asks), item 3
|
||||
unchanged (fast-socks5, courtesy). The Phase 7 "open items" are all
|
||||
resolved except OQ-SK-08's alksocks-side decision (item 3 of the ask
|
||||
ledger), which is unchanged by this.
|
||||
|
||||
The downstream motivation (2026-09-15 discussion): alksocks' three
|
||||
commands (CONNECT / BIND / UDP ASSOCIATE) need only a few alktunnels
|
||||
additions, and those additions are generally useful — they are the
|
||||
`ssh -L`/`-R` generalized shape that any dynamic-target consumer (SOCKS5
|
||||
included) composes on top of. This doc records the proposed graduation:
|
||||
what moves to alktunnels, what stays in alksocks, the design questions
|
||||
each piece raises, and how the whole thing feeds the vpn-like endgame
|
||||
(§The vpn-like endgame) and the planned `alknodes` assembly layer.
|
||||
|
||||
Prior reading (in the alksocks repo,
|
||||
`/workspace/@alkdev/alksocks/docs/research/`): `phase-0.md` (the OQ
|
||||
ledger + the POC register), `poc-bind-findings.md` (the BIND ask
|
||||
ledger), `poc-udp-associate-findings.md` (the composed-egress shape +
|
||||
open-cost measurement), `poc-connect-wasm-findings.md` (the
|
||||
dial-at-command-read layering).
|
||||
|
||||
## 1. The precise gap (what "graduate the proxy" means mechanically)
|
||||
|
||||
Mapping the SOCKS5 command surface onto alktunnels 0.1.0:
|
||||
|
||||
| SOCKS5 piece | alktunnels today | Gap |
|
||||
|---|---|---|
|
||||
| CONNECT (tcp proxy, egress) | dial establisher + `pump_bidi` + `local::dial_tcp` | none for **local** egress; **no dynamic-target open** for **composed** egress — params are `{resource, substrate}` (ADR-001) |
|
||||
| UDP ASSOCIATE (udp proxy) | connected-udp substrate + per-target tunnel table | none — the alksocks phase-0 note "no upstream ask exists here" holds; the composed form is per-target `udp` opens (POC #2 measured ~0.8 ms/destination) |
|
||||
| BIND (reverse tcp) | `listen_establisher` + `AcceptQueue` + `bind_tcp` | the two ledger asks: bound-addr discovery (reply#1's `BND.ADDR`) and peer metadata (reply#2's) — alksocks `poc-bind-findings.md` §Upstream-ask ledger |
|
||||
|
||||
So the graduation reduces to:
|
||||
|
||||
1. **One wire addition: a direct/dynamic open form** — "dial an
|
||||
arbitrary target," not "dial a named resource" (§2).
|
||||
2. **The two BIND metadata asks** — carriers to be decided (§3).
|
||||
|
||||
The pumping, teardown, codec, and session machinery are already done
|
||||
and POC-validated; the proxy producers on the alksocks side are thin
|
||||
adapters over the three callback seams (`DialFn`, `EgressFactory`,
|
||||
`BindAcceptFactory`), which the POCs validated in both local and
|
||||
composed variants.
|
||||
|
||||
## 2. The direct open form — the real design decision
|
||||
|
||||
### The constraint behind it
|
||||
|
||||
alktunnels ADR-001 deliberately pinned params as "resource, not an
|
||||
address": the producer owns the backing; rich addressing rides the
|
||||
`-D`/dynamic composition path (inside the payload), never the wire.
|
||||
That decision was correct for its use case and the no-forced-bind
|
||||
posture (AGENTS.md #9) stands: nothing here forces any side to bind a
|
||||
port. The use case that justifies an *additive* revisit is the one the
|
||||
POC ledger itself names: a consumer asking the producer to dial an
|
||||
arbitrary `host:port` — ssh's `direct-tcpip` shape, which ssh carries
|
||||
as its own channel type precisely because it is a different capability
|
||||
from opening a named resource. SOCKS5 CONNECT is the same capability:
|
||||
a dynamic target chosen at session time.
|
||||
|
||||
The capability is dangerous in exactly the way ADR-001's "resource,
|
||||
not address" framing protects against — arbitrary egress from the
|
||||
producer's network. The mitigation is not refusing the capability (a
|
||||
SOCKS5 service IS the capability) but separating it at the ACL: "dial
|
||||
`postgres-primary`" and "dial anything from my network" must not be
|
||||
the same grant.
|
||||
|
||||
### Options
|
||||
|
||||
**(a) Second op on the same ALPN — `channels/tunnel/direct`** with
|
||||
params `{substrate, target}` and its own scope (`tunnel:direct`).
|
||||
Clean in kind: this IS a different operation — dial what I name, vs.
|
||||
dial what the producer registered. The ACL separation is structural
|
||||
(scopes attach to op specs, not params); the POC #5 scope gate
|
||||
(`socks5:bind`) proved the pattern matters. Old producers reject the
|
||||
unknown op loudly (registry lookup fails — the SSH posture).
|
||||
|
||||
**(b) Optional `target` field in the existing params** + a
|
||||
`target`-implies-scope establisher check. ADR-001's consequences bless
|
||||
additive optional fields, but it couples the ACL to a field (scopes
|
||||
attach to the op), muddies the resource invariant, and forces the
|
||||
establisher to branch on the params shape. Weaker.
|
||||
|
||||
**(c) Resource-name convention** (`resource = "tcp:host:port"`). No
|
||||
wire change, but the resource id becomes ACL-blind (the registry
|
||||
grants by name; "any name of this shape" is not a scope), registry
|
||||
bookkeeping gets weird, and discovery listings leak addresses.
|
||||
Reject.
|
||||
|
||||
**Lean: (a).** One ALPN, two ops, two scopes — the same shape alktty's
|
||||
scope-gating and the POC #5 command gates took. Wire-stable questions
|
||||
(op id string, scope string, params field names, `target` address
|
||||
encoding) are pinned by an ADR before the first consumer — timing is
|
||||
right because alksocks is the first real consumer and nothing is
|
||||
published yet.
|
||||
|
||||
### The target-encoding sub-question
|
||||
|
||||
The direct op's `target` must carry what `DialFn` today gets from the
|
||||
registry's opaque backing string: `{host, port}` for tcp, `{host,
|
||||
port}` (or resolved-destination semantics) for udp, `{path}` for unix.
|
||||
The RFC-1928 ATYP encoding (IP v4/v6/domain + port — `TargetAddr`'s
|
||||
`to_be_bytes`) is the natural cross-substrate carrier: self-contained,
|
||||
no DNS dependency on the consumer side, and it is the encoding SOCKS5
|
||||
itself uses — the alksocks adapter maps it verbatim. Domain-form
|
||||
targets stay unresolved on the wire (producer-side resolution, the
|
||||
ssh `-D` semantic; POC #1/#2 both validated egress-side resolution).
|
||||
|
||||
### What does NOT move to alktunnels
|
||||
|
||||
The RFC 1928 conversation, reply codes, the command scope gates, the
|
||||
front doors, the `{addr, data}` datagram stage — all alksocks. The
|
||||
direct op is substrate-agnostic transport capability; it knows
|
||||
nothing about SOCKS5 (no `fast-socks5` dependency, no reply-code
|
||||
semantics upstream). The layering assertion holds: alktunnels stays
|
||||
SOCKS-ignorant; alksocks composes it.
|
||||
|
||||
## 3. The BIND metadata asks — carriers, and one alternative
|
||||
|
||||
alksocks' `poc-bind-findings.md` pinned two asks. The POC's stand-ins
|
||||
(factory carries the expected `bind_addr`; sentinel `0.0.0.0:0` as
|
||||
reply#2's peer) are honest interims, so the design question is how to
|
||||
retire them.
|
||||
|
||||
### The load-bearing constraint
|
||||
|
||||
The open-op reply is the only pre-data-plane wire moment, and it
|
||||
happens *after* the establisher. SOCKS5 BIND is address-first (reply#1
|
||||
must carry `BND.ADDR` before the app server dials); alktunnels' listen
|
||||
establisher is accept-first (its establishment phase pops an
|
||||
already-accepted connection — the open resolves *because* the app
|
||||
server dialed). The POC resolved the sequencing in shape (reply#1
|
||||
first; the tunnel open rides the accept future) — the asks are about
|
||||
fidelity, not feasibility.
|
||||
|
||||
### The two asks, restated as carriers
|
||||
|
||||
**Ask #1 — bound-addr discovery without consuming an accept.**
|
||||
Options:
|
||||
|
||||
- **Inspect-only open / `listen/addr` query op** — the plan carries
|
||||
`local_addr`, or a small query op on the listen resource. Wire
|
||||
addition (a query op is a new op, but `Query`-typed — cheap, no data
|
||||
plane).
|
||||
- **Out-of-band discovery** — the `OperationSpec.description`
|
||||
round-trips through discovery (OQ-TN-08); the producer's registry
|
||||
description carries the advertised address. Zero new wire; the cost
|
||||
is staleness (the description is install-time, the address may be
|
||||
OS-chosen) and per-session dynamic listeners have no description.
|
||||
- **Same-process assembly wiring** — the POC's stand-in formalized:
|
||||
the alksocks producer's accept factory holds the far side's
|
||||
`local_addr` directly when it constructed the listen resource
|
||||
itself. Works only when the producer owns both halves in-process —
|
||||
which is the common hub case (alknodes re-producers), but not the
|
||||
general composition case.
|
||||
|
||||
**Ask #2 — peer metadata on accepted handles.** Impossible to carry
|
||||
in the open-op reply (post-reply#2 does not exist as wire); the
|
||||
option space is (i) `TargetHandle` gaining an optional `peer:
|
||||
Option<SocketAddr>`-shaped metadata field (additive, no wire change —
|
||||
the metadata rides the establishment phase, which is where the accept
|
||||
happens), or (ii) accept-as-open: the accepted connection opens a
|
||||
reverse channel whose open-op params carry the peer (§3.1).
|
||||
|
||||
### 3.1 The alternative worth weighing: the `forwarded-tcpip` shape
|
||||
|
||||
ssh's `-R` far-side listener opens `forwarded-tcpip` channels back
|
||||
toward the client, with the listen target + peer address riding the
|
||||
open params. alktunnels has the whole reverse path already
|
||||
(`open_reverse_channel`, `TunnelSession::adopt`, producer-allocated
|
||||
channel IDs — consumer.rs). The shape:
|
||||
|
||||
1. The alksocks producer, on BIND, opens the listen-tunnel (or binds
|
||||
locally) and advertises reply#1 from whatever address source the
|
||||
carrier question resolves.
|
||||
2. When the far-side listener accepts, the *tunnel producer* opens a
|
||||
reverse channel toward the alksocks producer with `{listen_target,
|
||||
peer}` in params — peer fidelity for free.
|
||||
3. The alksocks producer correlates the reverse open to the waiting
|
||||
BIND session (a session-id ↔ quiet-wait-task registry), writes
|
||||
reply#2 with the real peer, and pumps.
|
||||
|
||||
Costs: a correlation registry on the SOCKS5 side; a direction flip in
|
||||
the composed flow (the accept-initiated open is toward the alksocks
|
||||
producer); the quiet wait must accept opens, not await a popped
|
||||
handle. Benefits: both asks' fidelity with zero special carriers —
|
||||
the metadata rides the open op, which is the wire's native
|
||||
"this connection is for X" moment. This is the wire-native answer and
|
||||
should be compared against the two-ask ledger rather than assuming
|
||||
the ledger's shapes.
|
||||
|
||||
Noted asymmetry: the local accept variant needs neither carrier (the
|
||||
listener and the accept are in-process; reply#1/#2 come from the real
|
||||
sockets, POC-validated). Both asks exist only for the composed
|
||||
variant. That suggests scoping the asks to the composed variant and
|
||||
letting the local variant stay as-is.
|
||||
|
||||
## 4. The callback→provider mapping (what alksocks ships)
|
||||
|
||||
The three seams stay callbacks; the graduation gives each a composed
|
||||
provider next to the `local`-socket one:
|
||||
|
||||
| alksocks seam | local provider (`local` feature) | composed provider (alktunnels) |
|
||||
|---|---|---|
|
||||
| `DialFn` (CONNECT) | `dial_tcp`/`dial_unix` (`local`) | open `channels/tunnel/direct` (or the direct-op form chosen) toward the target |
|
||||
| `EgressFactory` (ASSOCIATE) | one `UdpSocket` per association (POC #2's `LocalEgress`) | per-destination udp tunnel table (POC #2's `ComposedEgress` — the direct op dissolves its `resource_of` stand-in) |
|
||||
| `BindAcceptFactory` (BIND) | real `TcpListener` per session (POC #5's local variant) | listen-tunnel session (POC #5's composed variant — the metadata carrier decides reply#1/#2 fidelity) |
|
||||
|
||||
All six paths are individually POC-validated; nothing here is
|
||||
hypothesis-level except the metadata carriers (§3).
|
||||
|
||||
**Hub-relay transparency.** The terminate-and-re-produce hub
|
||||
(alkcall ADR-042, alktunnels' refinement) forwards tunnel data
|
||||
byte-for-byte. One structural note for the ADR: a hub that re-produces
|
||||
the direct op applies its OWN target policy at the re-produce hop
|
||||
(the hub's ACL, not the original opener's, governs the final dial) —
|
||||
correct-by-default (the hub is the network boundary) but worth pinning
|
||||
as the security model sentence.
|
||||
|
||||
## 5. The ask ledger, reconciled
|
||||
|
||||
The one-version alktunnels bump, post-graduation:
|
||||
|
||||
1. **The direct op** (§2) — the wire addition itself: op spec, scope
|
||||
constant, params schema, establisher shape (dial only — no
|
||||
listen/direct-BIND form), `DialFn` reuse.
|
||||
2. **Listen metadata** (§3) — bound-addr discovery + peer metadata,
|
||||
carriers per the ADR; OR the `forwarded-tcpip` shape as the
|
||||
combined alternative (§3.1). The POC ledger's two items map onto
|
||||
this item either way.
|
||||
3. **(alksocks courtesy, fast-socks5)** — unchanged from POC #5's
|
||||
ledger: publish `new_reply`; the `net` feature gate;
|
||||
`run_udp_proxy_custom`'s unconditional bind.
|
||||
|
||||
Item 3 is independent; items 1–2 are the single alktunnels version
|
||||
bump alksocks composes against.
|
||||
|
||||
## 6. The vpn-like layer — where this ends up
|
||||
|
||||
The graduation is the mechanical half of the vpn-like story; the
|
||||
addressing half rides on top:
|
||||
|
||||
- **Virtual addressing (OQ-SK-05's addendum, now concrete).** With a
|
||||
direct op, "map node/service → private IP" becomes a dial-callback
|
||||
translation: intercept `connect(10.x.y.z:p)`, match the subnet
|
||||
against a per-identity address table, open the direct op toward the
|
||||
owning node's resource (or the named resource the address maps
|
||||
to). No wire change — DST.ADDR/BND.ADDR are arbitrary RFC fields;
|
||||
the intercept point is the SOCKS5 front door (tun2proxy routes the
|
||||
host's traffic there, `--proxy socks5://...` natively). The
|
||||
POC #2 `resource_of` seam is the same translation at the datagram
|
||||
stage.
|
||||
- **The signing half is not a tunnels concern.** Attesting "node N
|
||||
serves 10.1.2.3:5432 in scope S" is a directory/identity-layer
|
||||
pattern (alkcall identities + a signed address→service table);
|
||||
alktunnels/alksocks consume it as dial-callback policy. Worth a
|
||||
prior-art check on tun2proxy's virtual-DNS/virtual-network support
|
||||
(alksocks phase-0 flagged this) when that layer is specced.
|
||||
- **alknodes (the planned assembly layer).** The hub/spoke shape the
|
||||
user describes — spokes expose resources (postgres, redis,
|
||||
containers), the hub re-produces them to privileged spokes, spokes
|
||||
can be hubs themselves (russian-doll: an internal private network
|
||||
with selective exposure) — is the terminate-and-re-produce story
|
||||
alktunnels' OQ-TN-12 deferred until a concrete consumer exists.
|
||||
alknodes is that consumer. What it needs from this graduation:
|
||||
the direct op (dynamic egress through the overlay), the listen
|
||||
surface (accept-side resources), and the metadata carriers (BIND
|
||||
fidelity through one or more hops). What it adds on its own layer:
|
||||
resource directory, identity→subnet mapping, hub ACL policy —
|
||||
all assembly, no new wire.
|
||||
|
||||
## 7. Open items for Phase 1 / ADRs
|
||||
|
||||
- The direct op's wire shape (op id, scope string, params encoding) —
|
||||
one-way door; ADR before the first consumer.
|
||||
- The listen-metadata carriers (or the `forwarded-tcpip` alternative)
|
||||
— same gate.
|
||||
- Whether BIND ships in alksocks' base crate + default accept variant
|
||||
(OQ-SK-08's remaining decision) — unchanged by this doc, but the
|
||||
carrier choice feeds it.
|
||||
- The scope-separation posture: `tunnel:direct` ≠ `tunnel:open` ≠
|
||||
ingress scopes; pin the convention sentence in the ADR.
|
||||
- The hub re-produce security-model sentence (§4).
|
||||
@@ -0,0 +1,674 @@
|
||||
# Review 002 — The Graduation Implementation (direct op + bind-first + forwarded op)
|
||||
|
||||
## Status
|
||||
|
||||
**Conformant with findings.** Six sessions reviewed
|
||||
(`52b6252..474844e` — the taskgraph fix + the six graduation feature
|
||||
commits): the direct op end to end, the bind-first establisher + accept
|
||||
dispatcher, the forwarded op's serving side, the local bind-first
|
||||
halves, and `TunnelListener`. Wire surface, scope separation, typed
|
||||
errors, teardown ownership, and the R-02/ADR-050 contracts all conform
|
||||
(verified against the alkcall 0.8.0 source, not just its docs). No
|
||||
[critical] findings. **One [minor] implementation gap (M-1) and a
|
||||
cluster of [major]-labeled doc/spec-sync items (D-1..D-4) that are
|
||||
doc-only in code impact** — the code is the wire-stable side and is
|
||||
correct; the *docs* lag it. Nothing blocks the graduation consumers.
|
||||
|
||||
Findings use prefix `M` (minor code), `D` (doc/spec sync), `N` (notes).
|
||||
This is the second review in this crate's series (numbering follows the
|
||||
alkcall house pattern — each review numbers independently).
|
||||
|
||||
## Scope
|
||||
|
||||
The six sessions' work at tree `474844e` reviewed against ADR-007,
|
||||
ADR-008 (as amended), the updated wire.md/consumer.md/producer.md, the
|
||||
AGENTS.md conventions, and — load-bearing — the alkcall 0.8.0 source
|
||||
(`/workspace/@alkdev/alkcall/src/channels/{operations,pump,manager,mux,source,client}.rs`,
|
||||
`src/registry/registration.rs`), cross-checking the six contracts this
|
||||
implementation leans on.
|
||||
|
||||
```
|
||||
Verified against: alktunnels 474844e; alkcall 0.8.0 (source-read)
|
||||
Reading list: src/{lib,params,producer,consumer,error,wire}.rs,
|
||||
src/local/mod.rs, tests/{harness,producer_bindfirst,producer_direct_op,
|
||||
producer_forwarded,consumer_listener,local_bindfirst}.rs,
|
||||
docs/architecture/*.md incl. decisions/007, 008
|
||||
Executed: cargo test (132 default / 157 --features local, doc-tests 2/2),
|
||||
clippy --all-targets -D warnings (native + wasm32), fmt --check,
|
||||
cargo doc --no-deps (0 warnings), cargo check + clippy on
|
||||
wasm32-unknown-unknown — all green, matching the commit messages' claims;
|
||||
cargo-llvm-cov 0.8.4 line/region coverage, default (95.63%) +
|
||||
--features local (93.66%) — see the coverage appendix
|
||||
Source-read (alkcall 0.8.0): pump_bidi shutdown-on-completion,
|
||||
teardown_channel drop-sender EOF, mux implicit-EOF sentinel,
|
||||
yield-once accept_bi + accepted-flag telemetry,
|
||||
Establishment::with_reply_field + reserved-key guard,
|
||||
open_channel_with_reply shape, registry input_schema gate
|
||||
(compiled at registration, runs after ACL, INVALID_INPUT on violation)
|
||||
```
|
||||
|
||||
## Severity legend
|
||||
|
||||
Same scale as review 001 / the alkcall reviews:
|
||||
|
||||
- **[critical]** — a decided spec invariant violated so a promised
|
||||
capability is unreachable end-to-end; or data corruption.
|
||||
- **[major]** — a core protocol path cannot serve a decided behavior;
|
||||
or a wire/ABI promise is not delivered by the code.
|
||||
- **[minor]** — drift, doc/spec inconsistency, or a missing
|
||||
convenience with no correctness impact.
|
||||
|
||||
---
|
||||
|
||||
# Checklist verdicts
|
||||
|
||||
1. **Wire conformance (ADR-007/008 + wire.md) — PASS.** The direct op's
|
||||
spec (`tunnel_direct_spec`) and the forwarded op's spec
|
||||
(`tunnel_forwarded_spec`) match wire.md §The Direct Op / §The
|
||||
Forwarded Op: op ids, `Sub` type, `alk/tunnel` ALPN marker, the
|
||||
per-substrate `if`/`then` target/peer sub-schemas with
|
||||
`additionalProperties: false`, and the three independent scopes
|
||||
(`tunnel:open`/`tunnel:direct`/`tunnel:forwarded`) — each proven
|
||||
never-implied by wire-level tests in both directions
|
||||
(`producer_direct_op.rs` `tunnel_open_scope_does_not_imply_*`,
|
||||
`producer_forwarded.rs::forwarded_scope_gate_is_independent_grant`).
|
||||
The `listen` token's positivity is enforced at three layers
|
||||
(schema `minimum: 1`, the `positive_u32` serde pin, the reply
|
||||
projection's `> 0` filter) with the mint never yielding 0
|
||||
(`mint_token` seeded at 1, wrap-skip pinned by test). The reply
|
||||
projection's asymmetry is deliberate and tested both ways:
|
||||
`parse_listen_reply` is the loud typed viewer (malformed →
|
||||
`MalformedReply`), `listen_reply_fields` the lenient session-surface
|
||||
projection (malformed → both-`None` + loud log) — the doc comments
|
||||
on both correctly state which to use when, and the additive posture
|
||||
(unknown reply fields tolerated) is pinned by test.
|
||||
|
||||
2. **Producer conformance — PASS.** The bind-first establisher is the
|
||||
awaited bounded establishment phase (registry gate → mint → bind →
|
||||
reply fields → `Establishment::new(plan)`), the plan is
|
||||
typed-opaque (`BindFirstPlan` downcast test), and the accept
|
||||
dispatcher is a faithful ADR-008 §1 implementation: `accept_bi`
|
||||
FIRST (the R-02 accepted-flag telemetry — verified in upstream
|
||||
`operations.rs:954-1000`, the wrapper's birth-teardown hint keys on
|
||||
it), the idle-stream EOF watch races the accept loop in `select!`,
|
||||
per-accept forwarded opens are isolated (drop the accept, keep
|
||||
serving — pinned by two tests), loop-critical accept errors end the
|
||||
loop, `drop(plan)` tears the listener down, and the handler's
|
||||
`JoinHandle` awaits every per-accept pump (R-02 — no forwarded pump
|
||||
outlives its listener). The forwarded-op serving handler parses
|
||||
params (deny-unknown + positive-listen serde pin) and accepts the
|
||||
`BiStream`, awaiting the injected handler's `JoinHandle` inline —
|
||||
a malformed open tears down at birth with no phantom channel
|
||||
(pinned: `forwarded_malformed_params_tear_down_loud`).
|
||||
`register_openable` (no establisher) for the forwarded op matches
|
||||
ADR-008 §3's no-establishment shape.
|
||||
|
||||
3. **Consumer conformance — PASS.** `TunnelSession::open_direct` is the
|
||||
separate constructor ADR-007 §5 pins (not a params enum — two
|
||||
scopes, the caller states which it holds); the session type, data
|
||||
planes, and teardown API are identical to `open`. `TunnelListener`
|
||||
holds the write half IDLE (never exposed, never pumped), projects
|
||||
the reply fields via the lenient path, and owns teardown
|
||||
(`close` = graceful `shutdown` → EOF sentinel → reap; `Drop` =
|
||||
implicit-EOF chunk → reap — the mux's implicit-EOF path is real,
|
||||
verified in upstream `mux.rs:67-82`). No `Clone` compile-asserted
|
||||
for both session types. `adopt` gains nothing — pinned by test at
|
||||
both the standalone and through-the-real-forwarded-registration
|
||||
levels.
|
||||
|
||||
4. **Conventions sweep — PASS with notes.** No `unwrap`/`expect`/
|
||||
panics outside tests (the `unreachable!` arms in
|
||||
`take_halves`/`pump_against` are unreachable by the documented
|
||||
data-plane-XOR-pump invariant and are the pre-existing v1 shape).
|
||||
No substrate types outside `src/local/` (grep-verified). No
|
||||
comments beyond doc comments (the inline `//` density in
|
||||
`producer.rs`/`local/mod.rs` is high but they are documenting
|
||||
ADR-pinned contracts, which AGENTS.md convention 1's exception
|
||||
covers — correctness constraints that would otherwise be missed).
|
||||
The wasm-clean invariant holds (`cargo check + clippy` on
|
||||
wasm32-unknown-unknown pass; `tokio` features unchanged; no new
|
||||
non-local deps). Feature-gate verification: both `cargo test`
|
||||
(default) and `cargo test --features local` pass.
|
||||
|
||||
5. **Conformance to the amended ADR-008 — PASS.** Amendment 1's
|
||||
producer-minted token is implemented exactly as pinned: minted at
|
||||
establisher time (non-zero-seeded `AtomicU32`, per-open fresh,
|
||||
per-producer unique, wrap-skip of the illegal 0), carried twice
|
||||
(plan + `listen` reply field), stamped per-dispatcher from its own
|
||||
plan (pinned by the two-open token-discipline test), and consumed
|
||||
by the opener-side correlation (assembly-side bookkeeping — the
|
||||
crate provides the op + params, not the wait state machine).
|
||||
|
||||
---
|
||||
|
||||
# Findings
|
||||
|
||||
## M-1 [minor] — The accept dispatcher's substrate fallback is silent and wrong-shaped
|
||||
|
||||
`src/producer.rs:766-768`
|
||||
(`make_bindfirst_accept_handler`):
|
||||
|
||||
```rust
|
||||
let substrate = serde_json::from_value::<TunnelParams>(input.clone())
|
||||
.map(|p| p.substrate)
|
||||
.unwrap_or(Substrate::Tcp);
|
||||
```
|
||||
|
||||
The listen open's input params have already passed the registry's
|
||||
schema gate (enum-validated) **and** the establisher's
|
||||
`parse_params` (which rejects unparseable input with
|
||||
`handler_error`), so in the wired path the fallback is unreachable.
|
||||
But the fallback shape itself is the problem: if the parse ever fails
|
||||
(a registry whose schema gate is looser, an assembly variant, future
|
||||
drift), the dispatcher silently stamps `Tcp` into every forwarded
|
||||
open — for a UDP listen tunnel that is a raw-vs-framed data-plane
|
||||
mismatch on the *opener* side, the exact invariant class review 001
|
||||
U-1 was about, and it degrades silently instead of loudly.
|
||||
|
||||
The same silent-fallback shape exists in no other handler: the
|
||||
forwarded-op handler (`make_forwarded_op_handler`,
|
||||
`src/producer.rs:993-1002`) parses and **returns** on failure
|
||||
(teardown-at-birth, the correct loud shape); the pump handler
|
||||
downcasts the plan and returns on failure.
|
||||
|
||||
**Suggested fix (small, mechanical):** on parse failure, log loud and
|
||||
return (teardown-at-birth — the channel reaps, the far listener dies
|
||||
with the plan, the same posture as every other early-return in this
|
||||
file). Alternatively, since the establisher already parsed the params
|
||||
successfully, stamp the substrate into `BindFirstPlan` at establisher
|
||||
time and drop the second parse entirely — one parse, one source of
|
||||
truth, no fallback branch at all. The second shape is strictly better
|
||||
(the plan is already the per-open carrier; the substrate is
|
||||
per-open-static) but changes a public-ish struct.
|
||||
|
||||
**Why not fixed inline:** the choice between "log-and-return" and
|
||||
"stamp the plan" is a small API-shape decision (`BindFirstPlan`
|
||||
gains a field — a plan-payload change, cheap now, pre-consumer) that
|
||||
the next session should make deliberately, and both need a
|
||||
failure-path test.
|
||||
|
||||
## D-1 [major-as-labeled, doc-only] — wire.md omits the `port` bounds the shipped schema enforces
|
||||
|
||||
The shipped direct-op and forwarded-op schemas pin
|
||||
`"port": {"minimum": 0, "maximum": 65535}` plus
|
||||
`"additionalProperties": false` on the target/peer objects
|
||||
(`src/params.rs:208-222`, `453-467`), and `listen` with
|
||||
`"minimum": 1` (`src/params.rs:442`). wire.md (the normative WHAT)
|
||||
says only `port: <u16>` and "integer > 0" in prose — it does not
|
||||
record the bounds as schema properties, nor the
|
||||
`additionalProperties: false` gate that makes cross-shape targets
|
||||
fail the registry's `INVALID_INPUT` class *before* the establisher
|
||||
(the behavior `malformed_target_is_schema_class_and_leaves_no_channel`
|
||||
pins on the wire). wire.md is the wire-stable contract doc: the
|
||||
schema details are part of the one-way door (ADR-007 §"Wire-stable":
|
||||
"the target sub-schema" is listed as wire-stable). The BAST doc
|
||||
(`bast.md`) covers only the data-plane codec, not the params schemas,
|
||||
so there is no second source recording these bounds.
|
||||
|
||||
**Impact:** none at runtime (the code is the gate); the risk is a
|
||||
future consumer implementing from wire.md alone and accepting a
|
||||
shape the reference producer rejects (or vice versa). Sync the doc
|
||||
before the first consumer (alksocks) ships.
|
||||
|
||||
## D-2 [major-as-labeled, doc-only] — wire.md's `if`/`then` structure differs textually from ADR-007's `oneOf`
|
||||
|
||||
ADR-007 §2 says "the target sub-schema is per-substrate (`oneOf` on
|
||||
the sibling `substrate` value, validated at establisher time — see
|
||||
Consequences)"; the shipped schema (and wire.md §The Forwarded Op's
|
||||
structure, and the unit tests pinning it) use **`allOf` of two
|
||||
`if`/`then` branches** instead. `oneOf` of full-target-schemas would
|
||||
actually be *wrong* here (a `unix`-substrate open would need to fail
|
||||
the `tcp` branch — plain `oneOf` on sibling-conditioned subschemas is
|
||||
awkward; `if`/`then` composes correctly with the base
|
||||
`properties`/`required`). The code is right; ADR-007's one-word
|
||||
schema-structure reference is stale. One-line ADR erratum (or an
|
||||
amendment note), plus ADR-008 §3's "validated at establisher time"
|
||||
(ADR-007 §2's parenthetical) vs the shipped registry-side gate: the
|
||||
establisher-time sentence in ADR-007 §2 line 82 ("validated at
|
||||
establisher time") contradicts both the shipped design and the
|
||||
wire.md text ("validated at establisher time" appears in
|
||||
wire.md §The Direct Op but the test proves the registry gate fires
|
||||
first — `malformed_target_is_schema_class_and_leaves_no_channel`
|
||||
asserts the establisher's dial never runs). The ADR/wire.md sentence
|
||||
should read "validated registry-side (the schema gate), before the
|
||||
establisher; re-checked by serde at establisher time as the
|
||||
backstop" — which is exactly what `src/params.rs`'s doc comments
|
||||
already say.
|
||||
|
||||
## D-3 [minor] — `jsonschema_valid` in `src/params.rs` tests is a stub, not a validator
|
||||
|
||||
`src/params.rs:628-637`: `jsonschema_valid` parses the value with
|
||||
serde and pattern-matches the substrate string — it validates the
|
||||
serde layer, not the JSON Schema. Both call-sites'
|
||||
names (`schema_rejects_unknown_substrate_value`,
|
||||
`direct_target_schema_gates_shape_per_substrate` — the latter
|
||||
correctly does *structural* schema assertions instead and is honest
|
||||
about it) overpromise for the former: `schema_rejects_unknown_
|
||||
substrate_value` does not execute the spec's `input_schema` at all.
|
||||
The registry-side gate IS exercised end-to-end by the integration
|
||||
tests (`schema_rejects_unknown_substrate_loudly`,
|
||||
`malformed_target_is_schema_class…`, the forwarded `listen: 0` gate),
|
||||
so coverage is not lost — but the unit test's name promises more
|
||||
than it delivers. Either rename it (e.g.
|
||||
`serde_rejects_unknown_substrate_value`) or drop it in favor of the
|
||||
integration probes. alkcall already compiles the real validators at
|
||||
registration; a `jsonschema` dev-dependency purely for this unit
|
||||
test is not warranted.
|
||||
|
||||
## D-4 [minor] — CHANGELOG "Unreleased" preamble is stale
|
||||
|
||||
`CHANGELOG.md`'s Unreleased section still opens with "implementation
|
||||
is decomposed in `tasks/tunnels/` and lands in the next release…
|
||||
the only behavior-affecting change so far (no wire or API change at
|
||||
this level yet)" — written before the six implementation commits.
|
||||
The section below it correctly lists all six features. The preamble
|
||||
contradicts the section it introduces. Trim the preamble to a
|
||||
one-liner (or drop it) before release.
|
||||
|
||||
## N-1 [note] — `parse_listen_reply`'s malformed-`listen` message vs the serde pin's semantics
|
||||
|
||||
`parse_listen_reply` requires `listen` to be "a positive u32" and
|
||||
rejects 0 (`src/params.rs:329-339`) — matching the forwarded params'
|
||||
wire contract (`minimum: 1`). Consistent and intentional, but note
|
||||
the asymmetry with `bound`: a `bound: null` and a `listen: null` are
|
||||
both treated as absent (`None | Some(Value::Null) => None`) — fine,
|
||||
JSON-null-as-absent is the standard lenient reading, and the loud
|
||||
path still fires for present-but-malformed shapes. No action; noting
|
||||
the deliberate shapes for the record since `TunnelListenReply`
|
||||
carries `deny_unknown_fields` while the *parse function* tolerates
|
||||
unknown fields — the struct-level attribute is only load-bearing if
|
||||
someone deserializes the struct directly from a full reply, which the
|
||||
doc comment explicitly warns against ("Parse the full reply with
|
||||
`parse_listen_reply`, never with `deny_unknown_fields`
|
||||
deserialization of the whole reply"). The warning is doing real
|
||||
work; keep both.
|
||||
|
||||
## N-2 [note] — The idle-stream EOF watch treats ANY byte as teardown
|
||||
|
||||
`make_bindfirst_accept_handler`'s `eof_watch` (`src/producer.rs:781-794`)
|
||||
triggers teardown on `Ok(0)`, `Err(_)`, **and** `Ok(1..)` — a
|
||||
misbehaving opener that writes to the idle listen channel also tears
|
||||
the listener down. The doc comment owns this ("a peer that writes is
|
||||
misbehaving; treat any non-empty read as an EOF trigger too"), and it
|
||||
is the safe direction (fail closed — the channel is not a data
|
||||
plane). Verified upstream that a `MpscSendStream` drop without
|
||||
`shutdown` produces the implicit-EOF chunk (the `Ok(0)` path) and a
|
||||
`shutdown` produces the sentinel — both arrive as `Ok(0)` at the far
|
||||
reader, so both `TunnelListener::close` (explicit shutdown) and
|
||||
`Drop` (implicit EOF) trigger the far teardown. Correct. No action;
|
||||
recorded because a reviewer reading only ADR-008 §1 ("its EOF
|
||||
cancels the loop") might implement the narrower `Ok(0)`-only check
|
||||
and hang on a write-happy peer — the code is right, the ADR sentence
|
||||
is thinner than the implementation.
|
||||
|
||||
## N-3 [note] — `make_forwarded_op_handler` parses params BEFORE `accept_bi`
|
||||
|
||||
`src/producer.rs:993-1006`: the malformed-params early return happens
|
||||
before `conn.accept_bi()`, so the R-02 accepted-flag telemetry reads
|
||||
`false` for a malformed-params open (the wrapper logs the
|
||||
birth-teardown hint). This is the deliberate reading of R-02 — the
|
||||
flag is telemetry for the "handler exited without accepting" *hint*,
|
||||
and a malformed-params open IS a birth-teardown shape — but the
|
||||
ordering means the hint fires for a handled-rejection case. Cosmetic
|
||||
telemetry noise at worst (a `tracing::debug!`); the test suite pins
|
||||
the teardown behavior, which is what matters. The alternative (accept
|
||||
first, then parse) would hold a stream open for a params set the
|
||||
producer already got wrong — the current order is the better one.
|
||||
Documented here so a future alkcall reviewer doesn't flag the flag.
|
||||
|
||||
## N-4 [note] — `SubstrateAddr` untagged leniency is schema-gated, and that's the right call (but it is a latent sharp edge)
|
||||
|
||||
The untagged `SubstrateAddr` accepts extra fields (untagged variants
|
||||
cannot `deny_unknown_fields`), so a cross-shape target like
|
||||
`{"path": "/p", "host": "h"}` parses as the `Path` variant at serde
|
||||
level. Both the direct-op and forwarded-op tests pin this asymmetry
|
||||
with the same rationale: the SCHEMA is the shape-vs-substrate gate
|
||||
(`additionalProperties: false` per branch). That holds for the
|
||||
registry-dispatched path. Two things to keep in view:
|
||||
|
||||
- The establisher-level backstop (the serde parse inside
|
||||
`direct_establisher`) keys `render_target` off the sibling
|
||||
`substrate` — a cross-shape target that sneaked past a looser
|
||||
registry would render *something* (`/p` for the unix case) rather
|
||||
than fail. Acceptable (garbage-in handled by the schema's absence),
|
||||
but worth an ADR-line if a future consumer runs a schema-less
|
||||
registry variant.
|
||||
- If `SubstrateAddr` grows a variant whose shapes overlap
|
||||
(e.g. a future `{host, port, flowlabel}`), untagged disambiguation
|
||||
starts ordering — at that point switch to
|
||||
`#[serde(untagged)]`-with-content or an internally-tagged form.
|
||||
Pre-consumer, additive; just don't let it happen by accident.
|
||||
|
||||
## N-5 [note] — The token-mint discipline is sound; the concurrent-mint shape is untestable at this layer and fine
|
||||
|
||||
`mint_token` is a relaxed-ordering `fetch_add` loop skipping 0. The
|
||||
"2^32 concurrent opens" wrap-collision math in the doc comment is
|
||||
right (and the collision consequence — an opener correlating a
|
||||
forwarded open to the wrong listener — requires the same producer to
|
||||
have 2^32 *live* listeners simultaneously, impossible under the
|
||||
256-channel per-connection cap; the token is per-producer-connection,
|
||||
and each open rides one connection). Worth noting the cap makes the
|
||||
wrap analysis even more favorable than the comment states. No action.
|
||||
|
||||
## N-6 [note] — Test-only race window in `bindfirst_open_surfaces_reply_fields_and_teardown_cascades`
|
||||
|
||||
`tests/consumer_listener.rs:276` (and several sibling tests) settle
|
||||
the forwarded open with `tokio::time::sleep(300ms)` then poll
|
||||
`channel_ids()`. Sleep-then-assert is the established house pattern
|
||||
in this suite (the harness has no push-based settle surface), but
|
||||
these are the tests most likely to flake on a loaded CI box (the
|
||||
forwarded open traverses: producer accept → call plane → opener
|
||||
dispatch → registry → wrapper → handler spawn). The suite has been
|
||||
3x-repeat stable historically (review 001's methodology); if CI ever
|
||||
shows a flake here, the fix is a bounded poll loop on
|
||||
`channel_ids()` (the teardown assertions already use that shape —
|
||||
extend it to the arrivals). No action now.
|
||||
|
||||
## N-7 [note] — `test_bind()`'s listener is a bare `Arc<u32>`
|
||||
|
||||
The test bind closures in `producer_bindfirst.rs` /
|
||||
`consumer_listener.rs` put `Arc::new(4242u32)` in the plan's opaque
|
||||
listener slot. Fine for tests that feed accepts via side channels —
|
||||
and the mismatch probe (`accept_pair_loop_downcast_miss_fails_loud`)
|
||||
proves the real dispatch path fails loud on exactly this shape —
|
||||
but if a test ever wired `local_bindfirst_accept_loop()` against
|
||||
`test_bind()`, the failure would surface as the generic
|
||||
`handler_error`, not a test bug message. The tests consistently use
|
||||
the queue loops instead. No action; the downcast-miss test covers
|
||||
the failure mode.
|
||||
|
||||
---
|
||||
|
||||
# What was checked and found conformant (highlights)
|
||||
|
||||
- **The token path end-to-end** (Amendment 1): mint (seed-1, wrap-skip)
|
||||
→ reply field (reserved-key guard upstream keeps `channel_id`
|
||||
wrapper-owned — verified in `operations.rs:927-946`) → lenient
|
||||
projection → forwarded-param keying → opener-side correlation →
|
||||
per-dispatcher own-plan stamping (two-open test) → hub-relay
|
||||
pass-through (params untouched, reply fields per-hop-truthful —
|
||||
upstream ADR-051 machinery, source-verified in the relay docs).
|
||||
- **The teardown cascades**: listener drop → implicit-EOF chunk
|
||||
(upstream `mux.rs` implicit-EOF path verified) → far dispatcher's
|
||||
idle-read fires → loop breaks → `drop(plan)` (listener dies) →
|
||||
pumps awaited → handler task exits → wrapper's handler-exit
|
||||
teardown reaps the serving-side entry. Both directions (close's
|
||||
explicit `shutdown` sentinel and Drop's implicit path) tested; the
|
||||
leak-free guarantee asserted via `channel_ids()` on both managers.
|
||||
- **Per-accept isolation**: forwarded-open failure (FORBIDDEN /
|
||||
NOT_FOUND / correlation miss) drops only that accept; the loop
|
||||
serves the next (two tests pin queue-drain + no-stray-channels).
|
||||
- **Scope separation** proven structurally at the wire level in all
|
||||
three pairwise directions, at both the raw-call and typed-session
|
||||
layers.
|
||||
- **CF-005/CF-006 identity surfaces**: the establisher witnesses the
|
||||
END CALLER's identity on base/direct/bind-first paths (witness
|
||||
wrapper for bind-first, inline for the others); the forwarded op's
|
||||
opener-side gate sees the producer's per-call identity (the
|
||||
upstream per-call overlay — tested via the scope gate).
|
||||
- **Schema-gate ordering**: ACL → input_schema → establisher →
|
||||
handler, verified in upstream `registration.rs` (all three dispatch
|
||||
entry points) + pinned by the tests that assert the establisher's
|
||||
dial never runs on a malformed target.
|
||||
- **Doc-test parity**: both `compile_fail` asserts (no-Clone) live
|
||||
and green.
|
||||
|
||||
---
|
||||
|
||||
# Remediation
|
||||
|
||||
Ordered smallest-first; nothing here blocks alksocks composition.
|
||||
|
||||
1. **M-1** — the accept dispatcher's substrate fallback (log-and-return,
|
||||
or stamp the substrate into `BindFirstPlan`). Small; needs a
|
||||
failure-path test.
|
||||
2. **D-3, D-4** — rename/strengthen the unit-test helper; trim the
|
||||
CHANGELOG preamble. Trivial.
|
||||
3. **D-1, D-2** — sync wire.md's schema bounds + ADR-007's
|
||||
oneOf→if/then wording + the "establisher-time" →
|
||||
"registry-side-gate, serde-backstop" sentence. Doc-only but
|
||||
wire-contract-adjacent: do it before the first consumer ships.
|
||||
|
||||
---
|
||||
|
||||
# Coverage review (appendix — `cargo-llvm-cov` 0.8.4, line/region mode)
|
||||
|
||||
Measured with `cargo llvm-cov` (default) and `cargo llvm-cov
|
||||
--features local`; branch-level instrumentation needs a nightly
|
||||
toolchain (`-Z coverage-options=branch`), so these are line/region
|
||||
numbers — branch coverage is analyzed manually below where it matters.
|
||||
|
||||
## Headline numbers
|
||||
|
||||
| Configuration | Line coverage | Note |
|
||||
|---|---|---|
|
||||
| default | **95.63%** (74 missed of 1692) | the wire/protocol surface |
|
||||
| `--features local` | **93.66%** (133 missed of 2098) | adds `src/local/mod.rs` (84.0% lines) |
|
||||
|
||||
By file (default / `local`): `error.rs` 100/100, `params.rs`
|
||||
100/100, `wire.rs` 96.0/96.0, `producer.rs` 93.8/94.7,
|
||||
`consumer.rs` 90.5/90.5, `local/mod.rs` —/84.0.
|
||||
|
||||
The numbers are healthy for this suite's design (the integration tests
|
||||
drive the real paths over the harness duplex and — with `local` — real
|
||||
sockets); what remains uncovered is overwhelmingly **defensive
|
||||
early-return arms that no wired path can reach** plus a handful of
|
||||
genuine, cheap-to-close gaps. Classification follows.
|
||||
|
||||
## Genuine gaps (worth closing)
|
||||
|
||||
### G-1 — `TunnelSession::open`/`open_direct`/`TunnelListener::open` params-serialize error closures
|
||||
`src/consumer.rs:94-97, 134-137, 487-490` (and
|
||||
`open_reverse_channel`'s twin at `src/consumer.rs:593-594`). All four
|
||||
are `serde_json::to_value(..).map_err(..)` arms over types with no
|
||||
non-string keys or fallible serializers — unreachable in practice, and
|
||||
the established house shape for this conversion. Closing these needs a
|
||||
test-only serializer poison (`serde_json` can't fail on these types
|
||||
without `try_collect` tricks). **Recommendation: leave uncovered; do
|
||||
not chase.** If the suite ever grows a shared "unreachable error arm"
|
||||
prose note, these four are its members. (Alternative: collapse the four
|
||||
duplicated arms into one `fn params_to_input<T: Serialize>(..)` helper
|
||||
in `consumer.rs` — then only ONE closure exists and the gap shrinks by
|
||||
3. Cosmetic; fine to skip.)
|
||||
|
||||
### G-2 — `TunnelListener::close`'s `shutdown` arm never observed at the far end
|
||||
`src/consumer.rs:529-531`: `close()` runs
|
||||
`write.shutdown().await` (the explicit EOF sentinel) then reaps — but
|
||||
no test currently observes the FAR accept dispatcher's idle-read
|
||||
firing from a `close()` (the graceful path). The Drop path (implicit
|
||||
EOF → far teardown) IS observed end-to-end
|
||||
(`bindfirst_open_surfaces_reply_fields_and_teardown_cascades`), and
|
||||
the close path is pinned at the reap level
|
||||
(`close_and_drop_reap_the_listen_channel`) — but the sentinel's
|
||||
far-side effect for `close()` specifically is asserted nowhere: the
|
||||
"the pushed accept was never consumed" assertion runs only in the
|
||||
Drop-shaped tests. **Recommendation: one small test** — open against a
|
||||
bind-first producer, push an accept, `listener.close().await`, then
|
||||
poll that the far accept loop is gone (the same queue-len assertion
|
||||
the Drop tests use). This is the only gap that touches a *promised*
|
||||
behavior (ADR-008 §1's teardown contract, graceful half) with no
|
||||
direct observation. Cheap: mirror
|
||||
`bindfirst_full_flow…`'s harness with a `close()` instead of a drop.
|
||||
|
||||
### G-3 — `TunnelSession::join`'s pump-`Err` arm (`src/consumer.rs:368-373`)
|
||||
The `JoinHandle` error path (pump aborted → `JoinError` → warn + `(0,
|
||||
0)`) never fires in tests: `close()`/`Drop` abort the pump and reap
|
||||
without joining; `join()` is only ever exercised on live pumps. A
|
||||
one-line test: open a session, `pump_against`, `close().await` (aborts
|
||||
the pump), then… `join` consumes self — the shape needs a
|
||||
`pump_against` session dropped into a spawn that is aborted before
|
||||
`join`. Practical shape: `let s = session.pump_against(dup).await;`
|
||||
take the pump out via `s.join()` racing a `s.abort()`-equivalent —
|
||||
there is no public abort on the session, so the test should close the
|
||||
channel out-of-band (`manager.teardown_channel`) while `join()` is
|
||||
pending: the pump's copies error/EOF, `pump.await` still resolves Ok —
|
||||
actually the JoinError arm fires only on abort/cancel, which the
|
||||
session type never does before `join`. **Recommendation: leave the arm
|
||||
as-is (defensive, log-only) but note it in the review as
|
||||
unreachable-by-public-API** — the only way to hit it is a pump
|
||||
panicking, which is out of contract. Alternatively add a
|
||||
`#[cfg(test)]`-only unit test driving `join` on a session whose pump
|
||||
handle was manually aborted via a test seam — not worth the surface
|
||||
pollution on a warn-log arm.
|
||||
|
||||
### G-4 — `local/mod.rs` `UnixListenerHalves` accept-first loop never driven (591-609) + `TcpListenerHalves::accept_pair` downcast path in the *queue*-shaped tests
|
||||
`UnixListenerHalves::accept_loop` (src/local/mod.rs:591-607) has zero
|
||||
coverage: no test constructs it (the unix dial test hand-rolls its own
|
||||
echo listener; the bind-first unix test uses `accept_pair`). It is the
|
||||
unix parity twin of the covered `TcpListenerHalves::accept_loop`
|
||||
(hit at line 107-125) and its body is 15 lines of the same shape.
|
||||
**Recommendation: one small unix listen-producer test** (mirror
|
||||
`producer_listen.rs::accept_loop_stops_feeding_after_close_and_maps_the_drop`
|
||||
with `bind_unix` + a temp path) — closes the entire 593-607 range and
|
||||
exercises `UnixListenerHalves` beyond the bind-first path. Low value
|
||||
(copy-shaped), but it is the largest single uncovered block in the
|
||||
crate.
|
||||
|
||||
### G-5 — The unnamed-peer error arms (unix)
|
||||
`src/local/mod.rs:626-633` (the `accept_pair` unnamed-peer
|
||||
`handler_error`) and `src/local/mod.rs:578-583` (the
|
||||
`local_addr` unnamed-bind error) never fire: the unix bind-first test
|
||||
binds a named client socket deliberately (the parity case), and no
|
||||
test binds an abstract/autobind unix listener. Both are
|
||||
fail-loud guards the ADR pins (ADR-008 §3 — "a producer that cannot
|
||||
determine the peer does not serve a bind-first listener over that
|
||||
substrate"). **Recommendation: one compact test** — bind an abstract
|
||||
unix listener (Linux: a leading-`\0` path via socket2, or
|
||||
`UnixListener::bind` on an unnamed autobind socket), call
|
||||
`local_addr()` (expect the "no filesystem path" `handler_error`), and
|
||||
drive one autobind-client accept through `accept_pair` expecting the
|
||||
unnamed-peer error. Cheap, deterministic, and it pins the two ADR
|
||||
sentences the doc comments promise.
|
||||
|
||||
### G-6 — UDP bind-family resolve edge (`src/local/mod.rs:180-181`)
|
||||
`connect_udp`'s `no addresses` arm (the resolver returning an empty
|
||||
list). `connect_udp_unresolvable_target_maps_to_dial_failed` covers
|
||||
the `to_socket_addrs` Err arm; the empty-success arm needs a
|
||||
resolvable-but-zero-address name, which is not deterministically
|
||||
producible with std resolution. **Recommendation: leave uncovered**
|
||||
(documented as the std resolver's unreachable-in-practice arm) — or
|
||||
refactor `connect_udp` to take the resolution behind a small
|
||||
`ResolveFn` seam so the empty case is unit-testable. The seam is the
|
||||
better long-term shape (it also removes the blocking
|
||||
`to_socket_addrs` call from the async context — see N-8 below), so if
|
||||
this gap is addressed, do it via the seam.
|
||||
|
||||
### G-7 — `UdpHalf` `poll_read`/`poll_flush` readiness-error arms
|
||||
(`src/local/mod.rs:369-377, 395-396, 422-423, 427, 520-521`): the
|
||||
`poll_recv_ready`/`poll_send_ready` `Ready(Err)` arms and the
|
||||
high-water `Pending`-after-drain arm. The veth sender-block test
|
||||
(`udp_half_write_side_blocks_stashes_and_drains_on_resolve`) covers
|
||||
the stash/queue/resolve spine (lines 505-528 mostly hit; 519-521's
|
||||
`poll_send_ready` Pending arm never resolves Pending inside the test
|
||||
window because the veth's EAGAIN pattern resolves readiness between
|
||||
polls). These arms are one-poll-window states — deterministic capture
|
||||
needs a mocked readiness source, not a socket. **Recommendation:
|
||||
leave; the veth test already pins the observable behavior (stash →
|
||||
retry → deliver), and the arms are unreachable differently per-kernel
|
||||
(host-notes in the test document this).**
|
||||
|
||||
## Deliberately uncovered (defensive; no action)
|
||||
|
||||
- **Handler early-return arms** (`src/producer.rs:446-455, 759-764,
|
||||
1004-1005`): the `accept_bi`-failed / wrong-plan-payload /
|
||||
`try_unwrap`-failed telemetry returns. A wired open always has a
|
||||
plan of the right type; these fire only on a protocol bug or a
|
||||
mismatched registration (assembly error, not wire state). Covered in
|
||||
spirit by the unit-level downcast tests. To hit them, a test would
|
||||
need a handler registered against a plan the establisher never
|
||||
produced — an upstream-invariant violation, not a code path.
|
||||
- **`warn!`/`error!` telemetry bodies** (`src/producer.rs:825, 833,
|
||||
838-849`; `src/consumer.rs:368-373`): per-accept forwarded-open
|
||||
failure logging (the FORBIDDEN isolation test hits the *drop*
|
||||
branch's effects but the tracing body's region shows missed because
|
||||
the macro expands past the branch) and the loop-critical accept
|
||||
error arm — wait: **the loop-critical arm IS behaviorally covered**
|
||||
(`accept-loop-ended` via the queue_loop's closed-listener Err in
|
||||
the teardown-cascade tests); the missed lines 838-849 are the
|
||||
second `tracing::warn!` expansion inside that arm. Logging regions
|
||||
in macro expansion show as missed lines while the branch executes.
|
||||
Verified: `listen_channel_eof_cancels_loop_and_tears_listener_down`
|
||||
drives a loop-critical accept error. No action.
|
||||
- **`unreachable!` arms** (`src/wire.rs:113`,
|
||||
`src/consumer.rs:248, 328, 333`): guarded by invariants
|
||||
(`current` set before the `else` branch; data-plane XOR pump; the
|
||||
spent-session double-take the type makes impossible). Standard
|
||||
posture; the `debug_assert`-shaped gaps review 001 already passed.
|
||||
- **`wire.rs:184`** — the `datagram_split_across_awkward_chunks_reassembles`
|
||||
test's failure panic (the test always passes the reassembly).
|
||||
- **`Default for DatagramReader`** (`src/wire.rs:82-84`): derive-shaped
|
||||
boilerplate; all tests construct via `new()`. Trivial to close if
|
||||
wanted (`let _: DatagramReader = Default::default();` in an existing
|
||||
codec test) — otherwise delete the impl in favor of `#[derive(Default)]`.
|
||||
- **`src/params.rs` 99.3%** — the two "missed regions" are the
|
||||
`serde_json::from_value` error closures inside
|
||||
`parse_listen_reply`'s bound/listen arms; the `listen_reply_rejects_malformed_shapes`
|
||||
unit test exercises both paths (the macro-expansion region
|
||||
accounting again). Line-level: 100%. No gap.
|
||||
- **`Debug` impls** (`src/producer.rs:66, 109`): `TargetHandle`'s and
|
||||
`BindFirstPlan`'s manual `fmt`. Never invoked in tests (no
|
||||
`{:?}` on those types). One-line fix if desired: `assert_ne!` two
|
||||
plans in the token-discipline unit test (forces `Debug`), or drop
|
||||
the manual impls for `#[derive(Debug)]` with the opaque fields
|
||||
skipped (`#[derive(Debug)]` on `Arc<dyn Any…>` prints the pointer —
|
||||
the manual impl exists to keep the listener opaque). Keep the impls;
|
||||
optionally add a `debug_fmt_smoke` unit test.
|
||||
- **`witness_establisher`'s `None` arm** (`src/producer.rs:917`): the
|
||||
registration wrapper's `None => establisher` branch — every test
|
||||
registers with `Some(witness)`. One test registering
|
||||
`register_tunnel_bindfirst_openable` with `identity_witness: None`
|
||||
would cover it; trivial.
|
||||
|
||||
## Branch-coverage notes (manual, no nightly needed here)
|
||||
|
||||
The instrumented run is line/region only (stable; branch mode requires
|
||||
`-Z coverage-options=branch`). The manually-reviewed decision points
|
||||
and their test-pinned states:
|
||||
|
||||
- `AcceptQueue::pop`'s three arms — all covered (`producer_listen.rs`:
|
||||
empty-then-push, closed-and-empty, the lost-wakeup window test).
|
||||
- `UdpHalf::poll_read`'s staged-frame / fresh-recv / WouldBlock / error
|
||||
arms — all covered except the `poll_recv_ready` Err/Pending arms
|
||||
(G-7).
|
||||
- `UdpHalf::poll_write`'s high-water arm — covered by the veth test
|
||||
(Pending observed); the inner `Poll::Ready(Err)` return (422-423)
|
||||
uncovered (G-7's class).
|
||||
- `pump_queue_to_socket`'s WouldBlock/ENOBUFS stash arm — covered
|
||||
(veth test); the `poll_send_ready` triple (519-521) uncovered except
|
||||
via the flush-driven path (G-7).
|
||||
- `parse_listen_reply`'s malformed arms — covered (unit test);
|
||||
line-level 100%.
|
||||
- The idle-read `Ok(_)` arm (`src/producer.rs:792`) — same body as the
|
||||
`Ok(0) | Err(_)` arm; llvm-cov counts the second match arm's
|
||||
region separately. The write-happy-peer teardown shape is
|
||||
documented (N-2 in the main review) but untested — a test would
|
||||
write a byte to the idle channel and assert far teardown. Same
|
||||
harness shape as the EOF tests; cheap to add with G-2 if desired
|
||||
(it pins the "any non-empty read tears down" posture).
|
||||
|
||||
## Verdict
|
||||
|
||||
No coverage *gap* hides a correctness risk in the shipped paths: the
|
||||
uncovered set is defensive arms + two doc-promised fail-loud behaviors
|
||||
(G-5) + one ADR-contract half (G-2). The suite's integration-first
|
||||
design (real sockets with `local`, real forwarded-openable
|
||||
registrations, real EOF sentinels) is why the uncovered residue is
|
||||
this small. Recommended additions, in order of value: **G-2, G-5, G-4,
|
||||
G-7's `poll_send_ready` Err arm (skip — kernel-dependent), G-6 via the
|
||||
ResolveFn seam (optional), G-3 (document-only)**. Combined with M-1's
|
||||
failure-path test, that is roughly one session's worth of test work.
|
||||
|
||||
## References
|
||||
|
||||
- The six commits: `0789ab6` (direct op), `d586a5d` (forwarded params
|
||||
+ plan type), `7d23a89` (bind-first establisher + accept
|
||||
dispatcher), `3fb26d9` (forwarded serving + reply projection),
|
||||
`514e0c1` (local bind-first halves), `474844e` (`TunnelListener`)
|
||||
- ADR-007, ADR-008 (as amended), wire.md, consumer.md, producer.md
|
||||
- alkcall 0.8.0 surfaces this work rides: `open_channel_with_reply`,
|
||||
`Establishment::with_reply_field` + the reserved-key guard,
|
||||
flavor-form op ids (ADR-047 amendment 3), `pump_bidi` (ADR-050),
|
||||
the registry `input_schema` gate, the R-02 accepted-flag telemetry
|
||||
+ birth-teardown hint
|
||||
- Review 001 (the prior review; U-1's framed-adapter fix is
|
||||
re-verified here by the direct-op UDP round-trip test)
|
||||
+189
-1
@@ -22,7 +22,10 @@ use bytes::Bytes;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadHalf, WriteHalf};
|
||||
|
||||
use crate::error::{ReverseOpenError, TunnelIoError, TunnelOpenError};
|
||||
use crate::params::{Substrate, TunnelParams, OP_TUNNEL_OPEN, TUNNEL_ALPN};
|
||||
use crate::params::{
|
||||
listen_reply_fields, Substrate, SubstrateAddr, TunnelDirectParams, TunnelParams,
|
||||
OP_TUNNEL_DIRECT, OP_TUNNEL_OPEN, TUNNEL_ALPN,
|
||||
};
|
||||
use crate::wire::{frame_datagram, DatagramReader};
|
||||
|
||||
/// The typed consumer session (ADR-005): one session = one channel =
|
||||
@@ -104,6 +107,46 @@ impl TunnelSession {
|
||||
))
|
||||
}
|
||||
|
||||
/// Open a tunnel channel toward a CALLER-NAMED target (the direct
|
||||
/// op — dynamic-target egress, ADR-007; the ssh `direct-tcpip` /
|
||||
/// SOCKS5 CONNECT shape). A separate constructor from [`open`](Self::open),
|
||||
/// not a params enum: `tunnel:direct` and `tunnel:open` are two
|
||||
/// scopes — two capabilities — and the caller states which one it
|
||||
/// holds.
|
||||
///
|
||||
/// Serializes `{substrate, target}`, calls
|
||||
/// `open_channel(OP_TUNNEL_DIRECT, …, TUNNEL_ALPN)` (plain
|
||||
/// `open_channel` — dial establishers never bind, so no reply
|
||||
/// fields are expected), and presents the identical session type,
|
||||
/// data planes, and teardown API as [`open`](Self::open).
|
||||
///
|
||||
/// Error surface: the base open errors minus `unknown_resource`
|
||||
/// (no registry lookup); a malformed target is the schema /
|
||||
/// `invalid_input`-class failure (registry-side); an old producer
|
||||
/// rejects the unknown op loudly (`NOT_FOUND` — the SSH posture).
|
||||
pub async fn open_direct(
|
||||
client: &ChannelClient,
|
||||
target: SubstrateAddr,
|
||||
substrate: Substrate,
|
||||
) -> Result<Self, TunnelOpenError> {
|
||||
let input =
|
||||
serde_json::to_value(&TunnelDirectParams { substrate, target }).map_err(|e| {
|
||||
TunnelOpenError::Open(ChannelOpenError::CallFailed {
|
||||
error: CallError::internal(format!("params serialize: {e}")),
|
||||
})
|
||||
})?;
|
||||
let (channel_id, send, recv) = client
|
||||
.open_channel(OP_TUNNEL_DIRECT, input, TUNNEL_ALPN)
|
||||
.await?;
|
||||
Ok(Self::from_halves(
|
||||
channel_id,
|
||||
substrate,
|
||||
client.manager().clone(),
|
||||
send,
|
||||
recv,
|
||||
))
|
||||
}
|
||||
|
||||
/// Adopt a producer-allocated channel ID (the reverse path, `-R`):
|
||||
/// install the data plane from the adopted halves. Early arrivals
|
||||
/// are parked by the manager and drained on adopt (the adoption
|
||||
@@ -364,6 +407,151 @@ pub struct TakenHalves {
|
||||
pub write: Box<dyn AsyncWrite + Send + Unpin>,
|
||||
}
|
||||
|
||||
/// The listen-flow consumer session (ADR-008 §4) — the sibling of
|
||||
/// [`TunnelSession`]: it OPENS a listen tunnel (`channels/tunnel/sub`
|
||||
/// — the establisher shape is the producer's per-registration choice,
|
||||
/// invisible here), reads the bind-first reply fields (`bound` /
|
||||
/// `listen`) via the lenient [`crate::params::listen_reply_fields`]
|
||||
/// projection, and owns the listen channel's teardown (ADR-005's
|
||||
/// ownership posture extended to the listen flow).
|
||||
///
|
||||
/// **The held stream (the one new thing):** a listen session's channel
|
||||
/// stream has NO data-plane role — its only job is to EOF when the
|
||||
/// session drops. The struct holds the channel's write half
|
||||
/// (`MpscSendStream`, the mux sender) IDLE and never pumps it; the
|
||||
/// drop → EOF ordering is structural (the mux sender drops → the mux
|
||||
/// pump writes the zero-length EOF sentinel → the far accept
|
||||
/// dispatcher's idle-stream watch fires and tears the far listener
|
||||
/// down — ADR-008 §1's contract). The stream is deliberately not
|
||||
/// exposed: pumping on a listen channel is meaningless (the far side's
|
||||
/// dispatcher ignores it too).
|
||||
///
|
||||
/// **Forwarded sessions are NOT owned by the listener.** They arrive
|
||||
/// as ordinary adopted-side [`TunnelSession`]s the assembly correlates
|
||||
/// via the [`listen`](Self::listen) token and owns (the correlation
|
||||
/// registry is assembly-side bookkeeping — consumer.md's posture).
|
||||
/// Listener teardown does NOT cascade to already-adopted forwarded
|
||||
/// sessions; the far listener dying stops NEW accepts only.
|
||||
///
|
||||
/// The session does **not** implement `Clone` (compile-asserted below):
|
||||
/// aliasing a listener would alias its teardown.
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// use alktunnels::consumer::TunnelListener;
|
||||
/// fn assert_clone<T: Clone>() {}
|
||||
/// assert_clone::<TunnelListener>();
|
||||
/// ```
|
||||
///
|
||||
/// Fields are `Option` so `close` can move them out of the `Drop`-impl
|
||||
/// type; an emptied listener drops as a no-op (teardown is idempotent —
|
||||
/// an already-reaped entry reaps `Err(UnknownChannel)`, ignored).
|
||||
pub struct TunnelListener {
|
||||
/// The adopted listen channel's ID (one session = one channel —
|
||||
/// OQ-TN-02's resolution; observability + correlation).
|
||||
pub channel_id: u32,
|
||||
manager: Option<ChannelManager>,
|
||||
/// The `bound` reply field (the real OS-chosen address) — `None`
|
||||
/// for accept-first/dial establishers or old producers (the Option
|
||||
/// posture). Informational surface, NOT addressing.
|
||||
bound: Option<SubstrateAddr>,
|
||||
/// The `listen` reply field (the producer-minted correlation
|
||||
/// token) — `None` for establishers that mint nothing (the Option
|
||||
/// posture).
|
||||
token: Option<u32>,
|
||||
/// The listen channel's write half, held IDLE — the structural
|
||||
/// teardown signal (see the type docs). Never pumped, never
|
||||
/// exposed.
|
||||
write: Option<MpscSendStream>,
|
||||
}
|
||||
|
||||
impl TunnelListener {
|
||||
/// Open a listen tunnel (the listen flow, ADR-008 §4). Calls the
|
||||
/// open op through [`ChannelClient::open_channel_with_reply`] (the
|
||||
/// U-2 surface, alkcall 0.8.0) — the full reply arrives, the
|
||||
/// bind-first reply fields are projected via
|
||||
/// [`crate::params::listen_reply_fields`], and the already-adopted
|
||||
/// channel entry is held (adopt-free construction — the client
|
||||
/// adopted it). A failed open never yields a listener (typed
|
||||
/// errors — ADR-049 §4; no phantom session).
|
||||
///
|
||||
/// `bound()`/`listen()` surface `None` when the establisher
|
||||
/// supplied nothing (accept-first/dial producers, or old producers
|
||||
/// — the additive posture); `bound` is informational surface and
|
||||
/// MUST NOT be treated as a dial target (the producer owns the
|
||||
/// backing — ADR-001's invariant).
|
||||
pub async fn open(
|
||||
client: &ChannelClient,
|
||||
params: TunnelParams,
|
||||
) -> Result<Self, TunnelOpenError> {
|
||||
let input = serde_json::to_value(¶ms).map_err(|e| {
|
||||
TunnelOpenError::Open(ChannelOpenError::CallFailed {
|
||||
error: CallError::internal(format!("params serialize: {e}")),
|
||||
})
|
||||
})?;
|
||||
let (channel_id, reply, send, _recv) = client
|
||||
.open_channel_with_reply(OP_TUNNEL_OPEN, input, TUNNEL_ALPN)
|
||||
.await?;
|
||||
let projected = listen_reply_fields(&reply);
|
||||
Ok(TunnelListener {
|
||||
channel_id,
|
||||
manager: Some(client.manager().clone()),
|
||||
bound: projected.bound,
|
||||
token: projected.listen,
|
||||
write: Some(send),
|
||||
})
|
||||
}
|
||||
|
||||
/// The `bound` reply field — the real OS-chosen bound address
|
||||
/// (port `0` resolved), present only when the establisher supplied
|
||||
/// it (bind-first producers). reply#1-class fidelity (BND.ADDR).
|
||||
/// Informational surface, never addressing.
|
||||
pub fn bound(&self) -> Option<&SubstrateAddr> {
|
||||
self.bound.as_ref()
|
||||
}
|
||||
|
||||
/// The `listen` reply field — the correlation token (ADR-008
|
||||
/// Amendment 1) the producer minted for this listen tunnel. The
|
||||
/// assembly keys its forwarded-op correlation map on it; the
|
||||
/// listener does not own the receipt queue (forwarded sessions
|
||||
/// arrive as separate adopted sessions on the manager).
|
||||
pub fn listen(&self) -> Option<u32> {
|
||||
self.token
|
||||
}
|
||||
|
||||
/// Tear the listen tunnel down: shut the held write half (the
|
||||
/// graceful EOF sentinel — the far accept dispatcher's teardown
|
||||
/// signal), then reap the adopted channel entry
|
||||
/// (`teardown_channel`). Returns whether the entry existed. The
|
||||
/// far-side teardown cascades (EOF → accept-loop cancel → far
|
||||
/// listener drop); forwarded sessions already adopted own their
|
||||
/// own teardown (per-session, ADR-005).
|
||||
pub async fn close(mut self) -> bool {
|
||||
if let Some(write) = self.write.as_mut() {
|
||||
let _ = write.shutdown().await;
|
||||
}
|
||||
self.write.take();
|
||||
self.manager
|
||||
.take()
|
||||
.map(|m| m.teardown_channel(self.channel_id).is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TunnelListener {
|
||||
/// Drop the held write half (the mux pump writes the implicit-EOF
|
||||
/// chunk — the far dispatcher's teardown signal) and sync-reap the
|
||||
/// adopted channel entry (`teardown_channel` is sync —
|
||||
/// best-effort; `Drop` cannot await). Dropping without close never
|
||||
/// leaks the channel entry (ADR-005's Drop posture, listen-
|
||||
/// flavored).
|
||||
fn drop(&mut self) {
|
||||
self.write.take();
|
||||
if let Some(manager) = self.manager.take() {
|
||||
let _ = manager.teardown_channel(self.channel_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read from the chunk stream until one complete datagram is
|
||||
/// assembled (the incremental decode path — ADR-003). `Ok(None)` on
|
||||
/// stream EOF; mid-datagram EOF is `TruncatedDatagram` (fail-loud).
|
||||
|
||||
@@ -20,6 +20,13 @@ pub enum TunnelError {
|
||||
/// An I/O failure on the session's data plane.
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// A reply field failed its typed projection — the wire carried a
|
||||
/// malformed shape (e.g. [`crate::params::parse_listen_reply`]
|
||||
/// found `bound` not substrate-shaped, a non-positive `listen`,
|
||||
/// or a reply with no `channel_id` at all). A misbehaving
|
||||
/// producer is never silently coerced.
|
||||
#[error("malformed reply: {0}")]
|
||||
MalformedReply(String),
|
||||
}
|
||||
|
||||
/// A failed tunnel open or adopt. A failure never yields a session
|
||||
|
||||
+19
-5
@@ -6,7 +6,9 @@
|
||||
//! is the sibling precedent): the producer half registers the
|
||||
//! `alk/tunnel` open op and pumps bytes between the channel and the
|
||||
//! substrate; the consumer half is the typed session
|
||||
//! ([`TunnelSession`]) that opens tunnel channels and owns teardown.
|
||||
//! ([`TunnelSession`]) that opens tunnel channels and owns teardown —
|
||||
//! plus [`TunnelListener`] (the listen-flow session, ADR-008) for the
|
||||
//! bind-first listen shape.
|
||||
//!
|
||||
//! A tunnel is a resource, not an address: the open-op params identify
|
||||
//! a produced resource + substrate ([`TunnelParams`], ADR-001); the
|
||||
@@ -27,12 +29,24 @@ pub mod wire;
|
||||
#[cfg(feature = "local")]
|
||||
pub mod local;
|
||||
|
||||
pub use consumer::{open_reverse_channel, TakenHalves, TunnelSession};
|
||||
pub use consumer::{open_reverse_channel, TakenHalves, TunnelListener, TunnelSession};
|
||||
pub use error::{ReverseOpenError, TunnelError, TunnelIoError, TunnelOpenError};
|
||||
pub use params::{establishment_reason, TUNNEL_ALPN, TUNNEL_OPEN_SCOPE};
|
||||
pub use params::{
|
||||
establishment_reason, listen_reply_fields, parse_listen_reply, tunnel_direct_spec,
|
||||
tunnel_forwarded_spec, SubstrateAddr, TunnelDirectParams, TunnelForwardedParams,
|
||||
TunnelListenReply, OP_TUNNEL_DIRECT, OP_TUNNEL_FORWARDED, TUNNEL_DIRECT_SCOPE,
|
||||
TUNNEL_FORWARDED_SCOPE,
|
||||
};
|
||||
pub use params::{tunnel_open_spec, ChannelOpenError, Substrate, TunnelParams, OP_TUNNEL_OPEN};
|
||||
pub use params::{TUNNEL_ALPN, TUNNEL_OPEN_SCOPE};
|
||||
pub use producer::{
|
||||
register_tunnel_listen_openable, AcceptFn, AcceptQueue, DialFn, ResourceRegistry, TargetHandle,
|
||||
TunnelEstablishError,
|
||||
bindfirst_establisher, make_bindfirst_accept_handler, make_forwarded_op_handler,
|
||||
register_tunnel_bindfirst_openable, register_tunnel_forwarded_openable,
|
||||
};
|
||||
pub use producer::{
|
||||
direct_establisher, direct_establisher_with_witness, register_tunnel_direct_openable,
|
||||
register_tunnel_listen_openable, AcceptFn, AcceptQueue, BindFirstAcceptLoop,
|
||||
BindFirstInjections, BindFirstPlan, BindFn, BoundListener, DialFn, ForwardedHandler,
|
||||
ForwardedOpener, ResourceRegistry, TargetHandle, TunnelEstablishError,
|
||||
};
|
||||
pub use wire::MAX_DATAGRAM_LEN;
|
||||
+240
-2
@@ -17,6 +17,16 @@
|
||||
//! as TCP). **Stdio is NOT here** — a spawned process's stdio is
|
||||
//! alktty's pipe mode (exit codes + signals are tty semantics);
|
||||
//! remote command execution composes via alktty, not here.
|
||||
//! - **Bind-first** (shape 3, ADR-008) — [`bind_tcp_first`] /
|
||||
//! [`bind_unix_first`] (the real-socket [`BindFn`] sources: the
|
||||
//! `bound` reply field's data — the REAL OS-chosen address, port 0
|
||||
//! resolved), [`TcpListenerHalves::accept_pair`] /
|
||||
//! [`UnixListenerHalves::accept_pair`] (one accept per call yielding
|
||||
//! `(peer, halves)` — the [`BindFirstAcceptLoop`] contract), and the
|
||||
//! assembly convenience closures [`local_bind_first()`] +
|
||||
//! [`local_bindfirst_accept_loop()`]. UDP bind-first is NOT here —
|
||||
//! a UDP socket has no accept; the per-peer associate shape is a
|
||||
//! separate design decision (the bind closure fails loud for udp).
|
||||
//!
|
||||
//! Error mapping: [`TunnelEstablishError`] variants (the POC's
|
||||
//! `Into<EstablishmentError>` path) — dial refusal → `dial_failed`,
|
||||
@@ -28,8 +38,11 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::params::Substrate;
|
||||
use crate::producer::{AcceptQueue, DialFn, TargetHandle, TunnelEstablishError};
|
||||
use crate::params::{Substrate, SubstrateAddr};
|
||||
use crate::producer::{
|
||||
AcceptQueue, BindFirstAcceptLoop, BindFn, BoundListener, DialFn, TargetHandle,
|
||||
TunnelEstablishError,
|
||||
};
|
||||
use crate::wire::{frame_datagram, DatagramCodecError, DatagramReader, MAX_DATAGRAM_LEN};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
|
||||
@@ -110,6 +123,37 @@ impl TcpListenerHalves {
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
/// ONE accept as a `(peer, halves)` pair — the BIND-FIRST shape's
|
||||
/// substrate half (ADR-008 §1): the accept dispatcher invokes the
|
||||
/// injected [`BindFirstAcceptLoop`] closure PER accept; this method
|
||||
/// is what the closure calls. The peer address rides `accept()`'s
|
||||
/// return value (the forwarded op's `peer` carrier — reply#2-class
|
||||
/// fidelity, ADR-008 §3).
|
||||
///
|
||||
/// This is deliberately NOT the [`AcceptQueue`]-feeding
|
||||
/// [`TcpListenerHalves::accept_loop`] (the accept-FIRST shape's
|
||||
/// contract — the establisher pops pre-accepted handles): the
|
||||
/// bind-first dispatcher owns the accept pacing, so one accept per
|
||||
/// closure call with the peer attached is the honest shape.
|
||||
pub async fn accept_pair(&self) -> Result<(SubstrateAddr, TargetHandle), TunnelEstablishError> {
|
||||
let (stream, peer) = self
|
||||
.listener
|
||||
.accept()
|
||||
.await
|
||||
.map_err(|e| TunnelEstablishError::DialFailed(format!("accept: {e}")))?;
|
||||
let (read, write) = stream.into_split();
|
||||
Ok((
|
||||
SubstrateAddr::HostPort {
|
||||
host: peer.ip().to_string(),
|
||||
port: peer.port(),
|
||||
},
|
||||
TargetHandle {
|
||||
read: Box::new(read),
|
||||
write: Box::new(write),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// --- UDP ----------------------------------------------------------------
|
||||
@@ -501,6 +545,122 @@ pub async fn dial_unix(path: &str) -> Result<TargetHandle, TunnelEstablishError>
|
||||
})
|
||||
}
|
||||
|
||||
/// Bind a unix-socket listener (the listen shape's assembly-side half,
|
||||
/// OQ-TN-14's dial parity — same wrapper shape as
|
||||
/// [`TcpListenerHalves`]). The binding decision belongs to the caller
|
||||
/// (OQ-TN-04); a stale socket file at `path` fails loud (the
|
||||
/// caller unlinks first — `bind` never silently reuses).
|
||||
pub fn bind_unix(path: &str) -> Result<UnixListenerHalves, TunnelEstablishError> {
|
||||
let listener = tokio::net::UnixListener::bind(path)
|
||||
.map_err(|e| TunnelEstablishError::ResourceShortage(format!("bind `{path}`: {e}")))?;
|
||||
Ok(UnixListenerHalves {
|
||||
listener: Arc::new(listener),
|
||||
})
|
||||
}
|
||||
|
||||
/// An assembly-owned unix-socket listener + its accept half. The
|
||||
/// accept loop ([`UnixListenerHalves::accept_loop`]) is the
|
||||
/// accept-FIRST shape's feed ([`AcceptQueue`]); the per-accept
|
||||
/// [`UnixListenerHalves::accept_pair`] is the bind-FIRST shape's
|
||||
/// (one accept = one `(peer, halves)` pair — the peer is the
|
||||
/// connecting path, `accept()`'s return value).
|
||||
pub struct UnixListenerHalves {
|
||||
listener: Arc<tokio::net::UnixListener>,
|
||||
}
|
||||
|
||||
impl UnixListenerHalves {
|
||||
/// The bound listener's socket path.
|
||||
pub fn local_addr(&self) -> Result<String, TunnelEstablishError> {
|
||||
self.listener
|
||||
.local_addr()
|
||||
.map_err(|e| TunnelEstablishError::HandlerError(format!("local_addr: {e}")))?
|
||||
.as_pathname()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.ok_or_else(|| {
|
||||
TunnelEstablishError::HandlerError(
|
||||
"unix listener has no filesystem path (unnamed/abstract bind)".to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The accept-FIRST shape's loop: accept until the listener fails,
|
||||
/// pushing each accepted handle into the queue (the assembly layer
|
||||
/// aborts this task on teardown). Unix peer addresses are not
|
||||
/// reliably readable from an accepted stream — the queue contract
|
||||
/// carries halves only, same as the TCP shape's loop.
|
||||
pub async fn accept_loop(&self, queue: AcceptQueue) -> Result<(), TunnelEstablishError> {
|
||||
loop {
|
||||
let (stream, _peer) = self
|
||||
.listener
|
||||
.accept()
|
||||
.await
|
||||
.map_err(|e| TunnelEstablishError::DialFailed(format!("accept: {e}")))?;
|
||||
let (read, write) = stream.into_split();
|
||||
let handle = TargetHandle {
|
||||
read: Box::new(read),
|
||||
write: Box::new(write),
|
||||
};
|
||||
queue.push(handle).await.map_err(|dropped| {
|
||||
TunnelEstablishError::HandlerError(format!(
|
||||
"queue closed; accepted connection dropped: {dropped:?}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
/// ONE accept as a `(peer, halves)` pair — the BIND-FIRST shape's
|
||||
/// substrate half for unix (OQ-TN-14 parity: the bind path is the
|
||||
/// same shape as [`TcpListenerHalves::accept_pair`]). The peer is
|
||||
/// the ACCEPTED side's bound path ([`UnixStream::peer_addr`]);
|
||||
/// an unnamed peer (an autobind client) fails loud — the
|
||||
/// forwarded op's `peer` param is required on the wire, so an
|
||||
/// addressless peer cannot be represented (ADR-008 §3).
|
||||
pub async fn accept_pair(&self) -> Result<(SubstrateAddr, TargetHandle), TunnelEstablishError> {
|
||||
let (stream, peer) = self
|
||||
.listener
|
||||
.accept()
|
||||
.await
|
||||
.map_err(|e| TunnelEstablishError::DialFailed(format!("accept: {e}")))?;
|
||||
let peer_path = peer
|
||||
.as_pathname()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.ok_or_else(|| {
|
||||
TunnelEstablishError::HandlerError(
|
||||
"unix accept: the client socket has no bound path (unnamed peer); \
|
||||
the forwarded `peer` param requires an address"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let (read, write) = stream.into_split();
|
||||
Ok((
|
||||
SubstrateAddr::Path { path: peer_path },
|
||||
TargetHandle {
|
||||
read: Box::new(read),
|
||||
write: Box::new(write),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind a unix-socket listener for the BIND-FIRST shape (the
|
||||
/// real-socket [`BindFn`] source, OQ-TN-14's dial/bind parity): the
|
||||
/// returned [`BoundListener::bound`] is the REAL bound socket path
|
||||
/// (the `bound` reply field's data — reply#1-class fidelity; the
|
||||
/// forwarded op's `peer` shape matches). The opaque `listener` half is
|
||||
/// the [`UnixListenerHalves`] wrapper — the assembly's
|
||||
/// [`BindFirstAcceptLoop`] closure downcasts to it and calls
|
||||
/// [`UnixListenerHalves::accept_pair`] per accept.
|
||||
pub async fn bind_unix_first(path: &str) -> Result<BoundListener, TunnelEstablishError> {
|
||||
let halves = bind_unix(path)?;
|
||||
let bound = halves
|
||||
.local_addr()
|
||||
.map(|path| SubstrateAddr::Path { path })?;
|
||||
Ok(BoundListener {
|
||||
listener: Arc::new(halves) as Arc<dyn std::any::Any + Send + Sync>,
|
||||
bound,
|
||||
})
|
||||
}
|
||||
|
||||
/// The local dial closure (the ADR-004 injection point): one
|
||||
/// [`DialFn`]-shaped function covering tcp/udp/unix — the assembly
|
||||
/// layer injects this (closed over nothing substrate-specific beyond
|
||||
@@ -517,3 +677,81 @@ pub fn local_dial() -> DialFn {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// --- Bind-first (shape 3, ADR-008 §1) ------------------------------------
|
||||
|
||||
/// Bind a TCP listener for the BIND-FIRST shape (the real-socket
|
||||
/// [`BindFn`] source — shape 3's backing, ADR-008 §1): `port 0` = the
|
||||
/// OS chooses, and the returned [`BoundListener::bound`] is the REAL
|
||||
/// resolved address (`local_addr()` — the `bound` reply field's data,
|
||||
/// reply#1-class fidelity). The caller-supplied address is deliberate
|
||||
/// ([`bind_tcp`]'s wildcard-bind posture applies unchanged).
|
||||
///
|
||||
/// The opaque `listener` half is the [`TcpListenerHalves`] wrapper —
|
||||
/// the assembly's [`BindFirstAcceptLoop`] closure downcasts to it and
|
||||
/// calls [`TcpListenerHalves::accept_pair`] per accept.
|
||||
pub async fn bind_tcp_first(addr: &str) -> Result<BoundListener, TunnelEstablishError> {
|
||||
let halves = bind_tcp(addr).await?;
|
||||
let bound = halves.local_addr().map(|a| SubstrateAddr::HostPort {
|
||||
host: a.ip().to_string(),
|
||||
port: a.port(),
|
||||
})?;
|
||||
Ok(BoundListener {
|
||||
listener: Arc::new(halves) as Arc<dyn std::any::Any + Send + Sync>,
|
||||
bound,
|
||||
})
|
||||
}
|
||||
|
||||
/// The TCP bind closure + accept-pair loop, bundled (the assembly
|
||||
/// convenience): [`local_bind_first()`] renders the [`BindFn`] shape-3
|
||||
/// expects (the dispatch substrate picks tcp/unix; udp fails loud —
|
||||
/// a UDP socket has no accept, so bind-first is not defined there),
|
||||
/// [`local_bindfirst_accept_loop()`] renders the [`BindFirstAcceptLoop`]
|
||||
/// that downcasts the plan's opaque listener to [`TcpListenerHalves`] /
|
||||
/// [`UnixListenerHalves`] and accepts one pair.
|
||||
pub fn local_bind_first() -> BindFn {
|
||||
Arc::new(move |substrate, backing: &str| {
|
||||
let backing = backing.to_string();
|
||||
Box::pin(async move {
|
||||
match substrate {
|
||||
Substrate::Tcp => bind_tcp_first(&backing).await,
|
||||
Substrate::Unix => bind_unix_first(&backing).await,
|
||||
Substrate::Udp => Err(TunnelEstablishError::HandlerError(
|
||||
"udp has no bind-first shape (no accept on a UDP socket); \
|
||||
use the dial shape"
|
||||
.to_string(),
|
||||
)),
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The bind-first accept loop (the assembly convenience paired with
|
||||
/// [`local_bind_first()`]): downcasts the plan's opaque listener to
|
||||
/// the concrete wrapper the bind closure produced and accepts ONE
|
||||
/// connection as a `(peer, halves)` pair per invocation (the
|
||||
/// dispatcher's per-accept contract — one closure call = one accept;
|
||||
/// the loop lives in the protocol's dispatcher).
|
||||
///
|
||||
/// The downcast is the assembly side of the type-erased injection
|
||||
/// pattern: the protocol crate never knows the listener's type, so the
|
||||
/// closure must — a listener not produced by this module's bind
|
||||
/// closures (or a mismatched substrate/backing pairing in the
|
||||
/// assembly's registry) fails loud as `handler_error`.
|
||||
pub fn local_bindfirst_accept_loop() -> BindFirstAcceptLoop {
|
||||
Arc::new(move |listener| {
|
||||
Box::pin(async move {
|
||||
if let Some(tcp) = listener.downcast_ref::<TcpListenerHalves>() {
|
||||
return tcp.accept_pair().await;
|
||||
}
|
||||
if let Some(unix) = listener.downcast_ref::<UnixListenerHalves>() {
|
||||
return unix.accept_pair().await;
|
||||
}
|
||||
Err(TunnelEstablishError::HandlerError(
|
||||
"bind-first accept loop: the plan's listener is not a local listener \
|
||||
(a mismatched bind closure / registry pairing)"
|
||||
.to_string(),
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
+943
-13
@@ -3,6 +3,23 @@
|
||||
//! `OperationSpec` builder, the scope/ALPN/op-id constants, and the
|
||||
//! typed establishment-reason helper (ADR-049 §4 surface).
|
||||
//!
|
||||
//! The direct op's params ([`TunnelDirectParams`] + [`SubstrateAddr`],
|
||||
//! ADR-007) and its spec builder live here too: `{substrate, target}`
|
||||
//! — the caller names the dial target at session time (the ssh
|
||||
//! `direct-tcpip` / SOCKS5 CONNECT shape), scope-gated by
|
||||
//! `tunnel:direct` (a separate grant from `tunnel:open`, never implied
|
||||
//! by it).
|
||||
//!
|
||||
//! The forwarded op's params ([`TunnelForwardedParams`], ADR-008 §3
|
||||
//! as amended) and its spec builder live here as well:
|
||||
//! `{listen, substrate, peer}` — the accept-as-open ingress op, keyed
|
||||
//! by the producer-minted correlation token (Amendment 1), scope-gated
|
||||
//! by `tunnel:forwarded` (a separate grant again). The bind-first
|
||||
//! listen open's reply projection ([`TunnelListenReply`] +
|
||||
//! [`parse_listen_reply`]) is the typed view of the two additive
|
||||
//! reply fields (`bound`, `listen`) the bind-first establisher
|
||||
//! contributes.
|
||||
//!
|
||||
//! The params layout is a one-way door once a consumer exists
|
||||
//! (ADR-001): `{resource, substrate}` only — the producer owns the
|
||||
//! backing; no URL-style addressing. `deny_unknown_fields` is the
|
||||
@@ -15,7 +32,9 @@ use alkcall::registry::spec::{
|
||||
AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::error::TunnelError;
|
||||
|
||||
/// The `channels/tunnel/sub` operation id (the open op).
|
||||
pub const OP_TUNNEL_OPEN: &str = "channels/tunnel/sub";
|
||||
@@ -27,6 +46,29 @@ pub const TUNNEL_ALPN: &str = "alk/tunnel";
|
||||
/// The scope gating tunnel opens (ADR-006). Stable once published.
|
||||
pub const TUNNEL_OPEN_SCOPE: &str = "tunnel:open";
|
||||
|
||||
/// The `channels/tunnel/direct` operation id (the direct op —
|
||||
/// dynamic-target egress, ADR-007; the flavor-form op-id convention,
|
||||
/// ADR-002 Amendment 1).
|
||||
pub const OP_TUNNEL_DIRECT: &str = "channels/tunnel/direct";
|
||||
|
||||
/// The scope gating the direct op (ADR-007 §3 / ADR-006 Amendment 1).
|
||||
/// A separate grant from [`TUNNEL_OPEN_SCOPE`] — **never implied by
|
||||
/// it**: "dial `postgres-primary`" and "dial anything from my
|
||||
/// network" are different capabilities. Stable once published.
|
||||
pub const TUNNEL_DIRECT_SCOPE: &str = "tunnel:direct";
|
||||
|
||||
/// The `channels/tunnel/forwarded` operation id (the forwarded op —
|
||||
/// accept-as-open ingress, ADR-008 §3; the flavor-form op-id
|
||||
/// convention, ADR-002 Amendment 1).
|
||||
pub const OP_TUNNEL_FORWARDED: &str = "channels/tunnel/forwarded";
|
||||
|
||||
/// The scope gating the forwarded op (ADR-008 §3 / ADR-006 Amendment
|
||||
/// 1). A separate grant from [`TUNNEL_OPEN_SCOPE`] and
|
||||
/// [`TUNNEL_DIRECT_SCOPE`] — **never implied by either**: receiving
|
||||
/// producer-initiated forwarded connections (ingress) is a different
|
||||
/// capability from dialing egress. Stable once published.
|
||||
pub const TUNNEL_FORWARDED_SCOPE: &str = "tunnel:forwarded";
|
||||
|
||||
/// The open-op input params (ADR-001). Wire-stable: both fields
|
||||
/// required; renames are a breaking wire change.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -52,6 +94,50 @@ pub enum Substrate {
|
||||
Unix,
|
||||
}
|
||||
|
||||
/// The substrate-shaped address object (ADR-007 §2): keyed by the
|
||||
/// sibling `substrate` field of the params — `tcp`/`udp` →
|
||||
/// [`SubstrateAddr::HostPort`] (domain-form hosts stay unresolved on
|
||||
/// the wire — producer-side resolution, the ssh `-D` semantic),
|
||||
/// `unix` → [`SubstrateAddr::Path`]. Untagged: the shapes are
|
||||
/// mutually exclusive in practice and self-describing. Also the
|
||||
/// `peer` value shape of the forwarded op (ADR-008 §3) — defined
|
||||
/// once here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum SubstrateAddr {
|
||||
HostPort { host: String, port: u16 },
|
||||
Path { path: String },
|
||||
}
|
||||
|
||||
/// The direct-op input params (ADR-007 §2). Wire-stable: both fields
|
||||
/// required; renames are a breaking wire change. The target's
|
||||
/// shape-vs-substrate correspondence is pinned by the spec's
|
||||
/// `input_schema` (per-substrate `oneOf`, validated registry-side
|
||||
/// before the establisher) and re-checked by serde here (the
|
||||
/// backstop — a mismatch parses as the wrong variant or fails).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TunnelDirectParams {
|
||||
/// The substrate discriminator; selects the data-plane framing
|
||||
/// (ADR-003) and the target's expected shape.
|
||||
pub substrate: Substrate,
|
||||
/// The dial target, keyed by `substrate` ([`SubstrateAddr`]).
|
||||
pub target: SubstrateAddr,
|
||||
}
|
||||
|
||||
impl TunnelDirectParams {
|
||||
/// Render the target to the backing-string form the `DialFn` /
|
||||
/// registry backings use: `"host:port"` for tcp/udp, the path for
|
||||
/// unix. The direct establisher calls this before dialing — the
|
||||
/// dial closure stays substrate-agnostic (ADR-007 §4).
|
||||
pub fn render_target(&self) -> String {
|
||||
match &self.target {
|
||||
SubstrateAddr::HostPort { host, port } => format!("{host}:{port}"),
|
||||
SubstrateAddr::Path { path } => path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The open-op spec (`channels/tunnel/sub`): `Sub`-typed, external,
|
||||
/// channel-open marker on the `alk/tunnel` ALPN, scope-gated by
|
||||
/// `tunnel:open` (ADR-006), input/output schemas per wire.md §The
|
||||
@@ -91,6 +177,339 @@ pub fn tunnel_open_spec() -> OperationSpec {
|
||||
)
|
||||
}
|
||||
|
||||
/// The direct-op spec (`channels/tunnel/direct`, ADR-007): `Sub`-typed,
|
||||
/// external, channel-open marker on the `alk/tunnel` ALPN, scope-gated
|
||||
/// by `tunnel:direct` (a separate grant from `tunnel:open` — never
|
||||
/// implied by it, ADR-006 Amendment 1), input/output schemas per
|
||||
/// wire.md §The Direct Op. The target sub-schema is per-substrate —
|
||||
/// `if`/`then` conditioned on the sibling `substrate` value
|
||||
/// (`tcp`/`udp` → `{host, port}`, `unix` → `{path}`) — so a malformed
|
||||
/// target fails the registry's schema validation (the typed
|
||||
/// `INVALID_INPUT` class) before the establisher runs.
|
||||
pub fn tunnel_direct_spec() -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
OP_TUNNEL_DIRECT,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"substrate": { "type": "string", "enum": ["tcp", "udp", "unix"] }
|
||||
},
|
||||
"required": ["substrate", "target"],
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "substrate": { "enum": ["tcp", "udp"] } },
|
||||
"required": ["substrate"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 65535
|
||||
}
|
||||
},
|
||||
"required": ["host", "port"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": { "substrate": { "const": "unix" } },
|
||||
"required": ["substrate"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string" }
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["channel_id"]
|
||||
}),
|
||||
vec![],
|
||||
AccessControl {
|
||||
required_scopes: vec![TUNNEL_DIRECT_SCOPE.to_string()],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new(TUNNEL_ALPN))
|
||||
.with_description(
|
||||
"Open a tunnel channel to a caller-named target (params: {substrate, target}; \
|
||||
dynamic-target egress)",
|
||||
)
|
||||
}
|
||||
|
||||
/// The bind-first listen open's reply projection (ADR-008 §2 as
|
||||
/// amended): the two additive fields the bind-first establisher
|
||||
/// contributes, extracted from the full success reply.
|
||||
///
|
||||
/// **The struct is the projected pair, not a deny-unknown wire gate.**
|
||||
/// The reply arrives as `{channel_id, bound?, listen?}` (the
|
||||
/// wrapper-reserved `channel_id` key plus the establisher's additive
|
||||
/// fields); unknown extra reply fields must be tolerated (the additive
|
||||
/// posture — old consumers ignore unknown fields). Parse the full
|
||||
/// reply with [`parse_listen_reply`], never with `deny_unknown_fields`
|
||||
/// deserialization of the whole reply. Both fields are `None` for
|
||||
/// accept-first/dial opens (the establisher supplied nothing).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TunnelListenReply {
|
||||
/// The real OS-chosen bound address (port `0` resolved),
|
||||
/// substrate-shaped — present only when the establisher supplied
|
||||
/// it. Informational surface, NOT addressing: a consumer MUST NOT
|
||||
/// treat `bound` as a dial target (the producer owns the backing —
|
||||
/// ADR-001's invariant); it exists for RFC-1928-reply-class
|
||||
/// fidelity (BND.ADDR) and observability.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bound: Option<SubstrateAddr>,
|
||||
/// The correlation token (ADR-008 Amendment 1): the u32 the
|
||||
/// establisher minted — the key the forwarded op's `listen` param
|
||||
/// carries. Present only when the establisher supplied it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub listen: Option<u32>,
|
||||
}
|
||||
|
||||
/// Project the bind-first listen open's reply (ADR-008 §2 as amended)
|
||||
/// out of the FULL success output [`Value`] — the shape
|
||||
/// `ChannelClient::open_channel_with_reply` returns. Extracts the
|
||||
/// optional `bound` + `listen` fields into [`TunnelListenReply`].
|
||||
///
|
||||
/// - Unknown extra reply fields are **tolerated** — the reply viewer
|
||||
/// must not deny unknown fields (the additive posture; the wire is
|
||||
/// plain JSON reply fields, and the projection is a typed view).
|
||||
/// - `channel_id` must be present and a positive integer (the
|
||||
/// wrapper-owned key — its absence means the caller is not looking
|
||||
/// at an open reply at all; fail loud).
|
||||
/// - A present-but-malformed `bound` (not substrate-shaped) or
|
||||
/// `listen` (not a positive u32) is a loud error — a misbehaving
|
||||
/// producer is never silently coerced ([`TunnelError::
|
||||
/// MalformedReply`]).
|
||||
pub fn parse_listen_reply(reply: &Value) -> Result<TunnelListenReply, TunnelError> {
|
||||
match reply.get("channel_id").and_then(Value::as_u64) {
|
||||
Some(id) if id > 0 => {}
|
||||
_ => {
|
||||
return Err(TunnelError::MalformedReply(
|
||||
"listen reply missing channel_id".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
let bound = match reply.get("bound") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
serde_json::from_value::<SubstrateAddr>(v.clone()).map_err(|e| {
|
||||
TunnelError::MalformedReply(format!("bound not substrate-shaped: {e}"))
|
||||
})?,
|
||||
),
|
||||
};
|
||||
let listen = match reply.get("listen") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_u64()
|
||||
.filter(|n| *n > 0 && *n <= u32::MAX as u64)
|
||||
.map(|n| n as u32)
|
||||
.ok_or_else(|| {
|
||||
TunnelError::MalformedReply("listen not a positive u32".to_string())
|
||||
})?,
|
||||
),
|
||||
};
|
||||
Ok(TunnelListenReply { bound, listen })
|
||||
}
|
||||
|
||||
/// Extract the bind-first listen open's reply fields (ADR-008 §2 as
|
||||
/// amended) from an open-op success reply — the **lenient, infallible
|
||||
/// projection** the typed consumer surface reads: both fields
|
||||
/// [`Option`], absent for dial/accept-first producers, unknown extra
|
||||
/// reply fields tolerated (the additive posture).
|
||||
///
|
||||
/// Delegates to [`parse_listen_reply`] (the loud typed viewer) and
|
||||
/// projects a malformed reply to both-`None`: the session type
|
||||
/// presents `Option` fields and never refuses construction over an
|
||||
/// informational field (`bound`/`listen` are informational surface,
|
||||
/// NOT addressing — ADR-008 §2's invariant; leniency here is safe by
|
||||
/// construction). A malformed shape is logged loud (`tracing::error!`)
|
||||
/// and surfaces as `None` — the session's caller cannot distinguish
|
||||
/// "producer supplied nothing" from "producer misbehaved"; use
|
||||
/// [`parse_listen_reply`] directly when that distinction matters (the
|
||||
/// fail-loud posture for consumers that must reject misbehaving
|
||||
/// producers). Shared with `TunnelListener` (`tunnels/tunnel-listener`
|
||||
/// reads the reply through this projection).
|
||||
pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply {
|
||||
match parse_listen_reply(reply) {
|
||||
Ok(projected) => projected,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"listen reply projection: malformed reply; both fields None (lenient view)"
|
||||
);
|
||||
TunnelListenReply {
|
||||
bound: None,
|
||||
listen: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The forwarded-op input params (ADR-008 §3 as amended). Wire-stable:
|
||||
/// all three fields required; renames are a breaking wire change.
|
||||
/// `deny_unknown_fields` is the loud-rejection posture (ADR-001's
|
||||
/// precedent — unknown fields fail, never silently ignored).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TunnelForwardedParams {
|
||||
/// The correlation token (ADR-008 Amendment 1): the u32 the
|
||||
/// bind-first establisher minted and returned in the listen
|
||||
/// open's `listen` reply field — NOT the listen channel's ID
|
||||
/// (that key is superseded: the serving-side handler never sees
|
||||
/// its own channel ID, and generic relays don't rewrite params).
|
||||
/// Positive by wire contract (schema `minimum: 1`; the serde pin
|
||||
/// is the backstop).
|
||||
#[serde(deserialize_with = "positive_u32")]
|
||||
pub listen: u32,
|
||||
/// The listen tunnel's substrate; shapes the forwarded channel's
|
||||
/// data-plane framing (ADR-003).
|
||||
pub substrate: Substrate,
|
||||
/// The accepted connection's remote address, keyed by the sibling
|
||||
/// `substrate` field ([`SubstrateAddr`]). Required in v1 — a
|
||||
/// producer that cannot determine the peer does not serve a
|
||||
/// bind-first listener over that substrate (ADR-008 §3).
|
||||
pub peer: SubstrateAddr,
|
||||
}
|
||||
|
||||
/// The serde-level pin behind `listen`'s schema `minimum: 1`: the
|
||||
/// correlation token is positive by contract (the establisher seeds
|
||||
/// the mint counter non-zero), so a `listen: 0` on the wire is a
|
||||
/// malformed open — rejected at parse time, never silently accepted.
|
||||
fn positive_u32<'de, D>(deserializer: D) -> Result<u32, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let v = u32::deserialize(deserializer)?;
|
||||
if v == 0 {
|
||||
return Err(serde::de::Error::custom("listen must be > 0"));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// The forwarded-op spec (`channels/tunnel/forwarded`, ADR-008 §3 as
|
||||
/// amended): `Sub`-typed, external, channel-open marker on the
|
||||
/// `alk/tunnel` ALPN, scope-gated by `tunnel:forwarded` (ingress — a
|
||||
/// separate grant from `tunnel:open` and `tunnel:direct`, ADR-006
|
||||
/// Amendment 1), input/output schemas per wire.md §The Forwarded Op.
|
||||
/// All three params required; the `peer` sub-schema is per-substrate —
|
||||
/// `if`/`then` conditioned on the sibling `substrate` value (`tcp`/
|
||||
/// `udp` → `{host, port}`, `unix` → `{path}`), so a malformed peer
|
||||
/// fails the registry's schema validation (the typed
|
||||
/// `INVALID_INPUT` class) before the opener-side handler runs.
|
||||
///
|
||||
/// **No establisher** on the forwarded op — the data plane starts
|
||||
/// immediately (the opener-side serving registration is
|
||||
/// `register_openable`, handler = correlate by `listen` + accept the
|
||||
/// `BiStream` + pump; the correlation registry is assembly-side
|
||||
/// bookkeeping, ADR-008 §3).
|
||||
pub fn tunnel_forwarded_spec() -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
OP_TUNNEL_FORWARDED,
|
||||
OperationType::Sub,
|
||||
Visibility::External,
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"listen": { "type": "integer", "minimum": 1 },
|
||||
"substrate": { "type": "string", "enum": ["tcp", "udp", "unix"] }
|
||||
},
|
||||
"required": ["listen", "substrate", "peer"],
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "substrate": { "enum": ["tcp", "udp"] } },
|
||||
"required": ["substrate"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"peer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 65535
|
||||
}
|
||||
},
|
||||
"required": ["host", "port"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": { "substrate": { "const": "unix" } },
|
||||
"required": ["substrate"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"peer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string" }
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"required": ["channel_id"]
|
||||
}),
|
||||
vec![],
|
||||
AccessControl {
|
||||
required_scopes: vec![TUNNEL_FORWARDED_SCOPE.to_string()],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.with_channel_open(ChannelOpenSpec::new(TUNNEL_ALPN))
|
||||
.with_description(
|
||||
"Receive a producer-initiated forwarded connection (accept-as-open; \
|
||||
params: {listen, substrate, peer}; correlate by the `listen` token)",
|
||||
)
|
||||
}
|
||||
|
||||
/// The establishment-failure reason code of a
|
||||
/// `channel:open_failed` error (`details.reason`): `dial_failed`,
|
||||
/// `unknown_resource`, `resource_shortage`, `handler_error`,
|
||||
@@ -180,12 +599,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_rejects_unknown_substrate_value() {
|
||||
let schema = tunnel_open_spec().input_schema;
|
||||
fn serde_rejects_unknown_substrate_value() {
|
||||
// The serde layer (the enum deserializer), not the JSON Schema —
|
||||
// the registry's real schema gate is pinned end-to-end by the
|
||||
// integration probes (`schema_rejects_unknown_substrate_loudly`,
|
||||
// `malformed_target_is_schema_class_and_leaves_no_channel`, the
|
||||
// forwarded `listen: 0` gate).
|
||||
let substrate_ok = json!({"resource": "r", "substrate": "udp"});
|
||||
let substrate_bad = json!({"resource": "r", "substrate": "sctp"});
|
||||
assert!(jsonschema_valid(&schema, &substrate_ok));
|
||||
assert!(!jsonschema_valid(&schema, &substrate_bad));
|
||||
assert!(serde_json::from_value::<TunnelParams>(substrate_ok).is_ok());
|
||||
assert!(serde_json::from_value::<TunnelParams>(substrate_bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -206,14 +629,521 @@ mod tests {
|
||||
assert_eq!(establishment_reason(&local_only), None);
|
||||
}
|
||||
|
||||
fn jsonschema_valid(_schema: &serde_json::Value, value: &serde_json::Value) -> bool {
|
||||
let Ok(params) = serde_json::from_value::<TunnelParams>(value.clone()) else {
|
||||
return false;
|
||||
// --- direct op (ADR-007) -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn direct_params_round_trip_per_substrate() {
|
||||
let cases: Vec<(Substrate, serde_json::Value, SubstrateAddr)> = vec![
|
||||
(
|
||||
Substrate::Tcp,
|
||||
json!({"host": "db.internal", "port": 5432}),
|
||||
SubstrateAddr::HostPort {
|
||||
host: "db.internal".to_string(),
|
||||
port: 5432,
|
||||
},
|
||||
),
|
||||
(
|
||||
Substrate::Udp,
|
||||
json!({"host": "10.0.0.7", "port": 53}),
|
||||
SubstrateAddr::HostPort {
|
||||
host: "10.0.0.7".to_string(),
|
||||
port: 53,
|
||||
},
|
||||
),
|
||||
(
|
||||
Substrate::Unix,
|
||||
json!({"path": "/run/postgresql/.s.PGSQL.5432"}),
|
||||
SubstrateAddr::Path {
|
||||
path: "/run/postgresql/.s.PGSQL.5432".to_string(),
|
||||
},
|
||||
),
|
||||
];
|
||||
for (substrate, target, addr) in cases {
|
||||
let params = TunnelDirectParams {
|
||||
substrate,
|
||||
target: addr.clone(),
|
||||
};
|
||||
let v = serde_json::to_value(¶ms).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"substrate": substrate_str(substrate), "target": target})
|
||||
);
|
||||
let back: TunnelDirectParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, params);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substrate_addr_untagged_disambiguation() {
|
||||
let hostport: SubstrateAddr =
|
||||
serde_json::from_value(json!({"host": "h", "port": 1})).unwrap();
|
||||
let path: SubstrateAddr = serde_json::from_value(json!({"path": "/p"})).unwrap();
|
||||
assert_eq!(
|
||||
hostport,
|
||||
SubstrateAddr::HostPort {
|
||||
host: "h".to_string(),
|
||||
port: 1
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
path,
|
||||
SubstrateAddr::Path {
|
||||
path: "/p".to_string()
|
||||
}
|
||||
);
|
||||
// Host/port without the host field fails; an array fails.
|
||||
assert!(serde_json::from_value::<SubstrateAddr>(json!({"port": 1})).is_err());
|
||||
assert!(serde_json::from_value::<SubstrateAddr>(json!([1, 2])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_params_unknown_fields_rejected() {
|
||||
let v = json!({
|
||||
"substrate": "tcp",
|
||||
"target": {"host": "h", "port": 1},
|
||||
"resource": "sneaky"
|
||||
});
|
||||
assert!(serde_json::from_value::<TunnelDirectParams>(v).is_err());
|
||||
// The untagged target is lenient about extra target fields
|
||||
// (untagged variants carry no deny_unknown_fields) — a
|
||||
// cross-shape target parses as whichever variant fits. The
|
||||
// SCHEMA is the gate that rejects shape-vs-substrate
|
||||
// mismatches registry-side (additionalProperties: false per
|
||||
// branch; direct_target_schema_gates_shape_per_substrate).
|
||||
// The establisher's serde parse is the backstop for the
|
||||
// shape it can actually use (render_target keys off the
|
||||
// sibling substrate).
|
||||
let cross_shape = json!({
|
||||
"substrate": "unix",
|
||||
"target": {"path": "/p", "host": "h"}
|
||||
});
|
||||
let parsed: TunnelDirectParams = serde_json::from_value(cross_shape).unwrap();
|
||||
assert_eq!(
|
||||
parsed.target,
|
||||
SubstrateAddr::Path {
|
||||
path: "/p".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_params_missing_fields_rejected() {
|
||||
let no_target = json!({"substrate": "tcp"});
|
||||
let no_substrate = json!({"target": {"host": "h", "port": 1}});
|
||||
assert!(serde_json::from_value::<TunnelDirectParams>(no_target).is_err());
|
||||
assert!(serde_json::from_value::<TunnelDirectParams>(no_substrate).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_spec_matches_wire_md() {
|
||||
let spec = tunnel_direct_spec();
|
||||
assert_eq!(spec.name, "channels/tunnel/direct");
|
||||
assert_eq!(spec.op_type, OperationType::Sub);
|
||||
assert_eq!(spec.visibility, Visibility::External);
|
||||
assert_eq!(
|
||||
spec.input_schema["properties"]["substrate"]["enum"],
|
||||
json!(["tcp", "udp", "unix"])
|
||||
);
|
||||
assert_eq!(
|
||||
spec.input_schema["required"],
|
||||
json!(["substrate", "target"])
|
||||
);
|
||||
assert_eq!(spec.output_schema["properties"]["channel_id"]["minimum"], 1);
|
||||
assert_eq!(
|
||||
spec.access_control.required_scopes,
|
||||
vec![TUNNEL_DIRECT_SCOPE.to_string()]
|
||||
);
|
||||
let channel_open = spec.channel_open.as_ref().expect("channel-open marker");
|
||||
assert_eq!(channel_open.alpn, TUNNEL_ALPN);
|
||||
assert!(spec.description.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_target_schema_gates_shape_per_substrate() {
|
||||
// The oneOf/if-then structure: per-substrate target shapes are
|
||||
// enforced by the schema itself (registry-side, before the
|
||||
// establisher). Structural assertions per tunnels/params'
|
||||
// precedent (no jsonschema dep here).
|
||||
let schema = tunnel_direct_spec().input_schema;
|
||||
let allof = schema["allOf"].as_array().expect("allOf branches");
|
||||
assert_eq!(allof.len(), 2, "tcp/udp branch + unix branch");
|
||||
let tcpudp_then = &allof[0]["then"]["properties"]["target"];
|
||||
let unix_then = &allof[1]["then"]["properties"]["target"];
|
||||
assert_eq!(
|
||||
allof[0]["if"]["properties"]["substrate"]["enum"],
|
||||
json!(["tcp", "udp"])
|
||||
);
|
||||
assert_eq!(
|
||||
allof[1]["if"]["properties"]["substrate"]["const"],
|
||||
json!("unix")
|
||||
);
|
||||
assert_eq!(
|
||||
tcpudp_then["required"],
|
||||
json!(["host", "port"]),
|
||||
"tcp/udp target requires host+port"
|
||||
);
|
||||
assert_eq!(tcpudp_then["properties"]["port"]["maximum"], json!(65535));
|
||||
assert_eq!(unix_then["required"], json!(["path"]));
|
||||
assert_eq!(
|
||||
tcpudp_then["additionalProperties"],
|
||||
json!(false),
|
||||
"cross-shape fields fail the schema"
|
||||
);
|
||||
assert_eq!(unix_then["additionalProperties"], json!(false));
|
||||
assert_eq!(allof[0]["then"]["properties"]["target"]["type"], "object");
|
||||
assert_eq!(allof[1]["then"]["properties"]["target"]["type"], "object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_target_matches_registry_backing_form() {
|
||||
let tcp = TunnelDirectParams {
|
||||
substrate: Substrate::Tcp,
|
||||
target: SubstrateAddr::HostPort {
|
||||
host: "db.internal".to_string(),
|
||||
port: 5432,
|
||||
},
|
||||
};
|
||||
let allowed = ["tcp", "udp", "unix"];
|
||||
matches!(
|
||||
substrate_str(params.substrate),
|
||||
"tcp" | "udp" | "unix" if allowed.contains(&substrate_str(params.substrate))
|
||||
)
|
||||
assert_eq!(tcp.render_target(), "db.internal:5432");
|
||||
let udp = TunnelDirectParams {
|
||||
substrate: Substrate::Udp,
|
||||
target: SubstrateAddr::HostPort {
|
||||
host: "10.0.0.7".to_string(),
|
||||
port: 53,
|
||||
},
|
||||
};
|
||||
assert_eq!(udp.render_target(), "10.0.0.7:53");
|
||||
let unix = TunnelDirectParams {
|
||||
substrate: Substrate::Unix,
|
||||
target: SubstrateAddr::Path {
|
||||
path: "/run/postgresql/.s.PGSQL.5432".to_string(),
|
||||
},
|
||||
};
|
||||
assert_eq!(unix.render_target(), "/run/postgresql/.s.PGSQL.5432");
|
||||
}
|
||||
|
||||
// --- forwarded op + the listen-reply projection (ADR-008, amended) --
|
||||
|
||||
#[test]
|
||||
fn forwarded_params_round_trip_per_substrate() {
|
||||
let cases: Vec<(Substrate, serde_json::Value, SubstrateAddr)> = vec![
|
||||
(
|
||||
Substrate::Tcp,
|
||||
json!({"host": "198.51.100.4", "port": 55231}),
|
||||
SubstrateAddr::HostPort {
|
||||
host: "198.51.100.4".to_string(),
|
||||
port: 55231,
|
||||
},
|
||||
),
|
||||
(
|
||||
Substrate::Udp,
|
||||
json!({"host": "10.0.0.7", "port": 53}),
|
||||
SubstrateAddr::HostPort {
|
||||
host: "10.0.0.7".to_string(),
|
||||
port: 53,
|
||||
},
|
||||
),
|
||||
(
|
||||
Substrate::Unix,
|
||||
json!({"path": "/run/postgresql/.s.PGSQL.5432"}),
|
||||
SubstrateAddr::Path {
|
||||
path: "/run/postgresql/.s.PGSQL.5432".to_string(),
|
||||
},
|
||||
),
|
||||
];
|
||||
for (substrate, peer, addr) in cases {
|
||||
let params = TunnelForwardedParams {
|
||||
listen: 918273,
|
||||
substrate,
|
||||
peer: addr,
|
||||
};
|
||||
let v = serde_json::to_value(¶ms).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"listen": 918273,
|
||||
"substrate": substrate_str(substrate),
|
||||
"peer": peer,
|
||||
})
|
||||
);
|
||||
let back: TunnelForwardedParams = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, params);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_params_unknown_fields_rejected() {
|
||||
let v = json!({
|
||||
"listen": 918273,
|
||||
"substrate": "tcp",
|
||||
"peer": {"host": "h", "port": 1},
|
||||
"channel": 7
|
||||
});
|
||||
assert!(serde_json::from_value::<TunnelForwardedParams>(v).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_params_missing_fields_rejected() {
|
||||
let no_listen = json!({"substrate": "tcp", "peer": {"host": "h", "port": 1}});
|
||||
let no_substrate = json!({"listen": 1, "peer": {"host": "h", "port": 1}});
|
||||
let no_peer = json!({"listen": 1, "substrate": "tcp"});
|
||||
assert!(serde_json::from_value::<TunnelForwardedParams>(no_listen).is_err());
|
||||
assert!(serde_json::from_value::<TunnelForwardedParams>(no_substrate).is_err());
|
||||
assert!(serde_json::from_value::<TunnelForwardedParams>(no_peer).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_params_listen_zero_rejected() {
|
||||
let v = json!({
|
||||
"listen": 0,
|
||||
"substrate": "tcp",
|
||||
"peer": {"host": "h", "port": 1}
|
||||
});
|
||||
assert!(
|
||||
serde_json::from_value::<TunnelForwardedParams>(v).is_err(),
|
||||
"the correlation token is positive by contract (schema minimum 1; \
|
||||
the serde pin is the backstop)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_reply_full_projection() {
|
||||
let reply = json!({
|
||||
"channel_id": 7,
|
||||
"bound": {"host": "203.0.113.9", "port": 42113},
|
||||
"listen": 918273
|
||||
});
|
||||
let projected = parse_listen_reply(&reply).unwrap();
|
||||
assert_eq!(
|
||||
projected,
|
||||
TunnelListenReply {
|
||||
bound: Some(SubstrateAddr::HostPort {
|
||||
host: "203.0.113.9".to_string(),
|
||||
port: 42113,
|
||||
}),
|
||||
listen: Some(918273),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_reply_bare_projection() {
|
||||
// The accept-first/dial shape: the establisher contributed
|
||||
// nothing — both fields None.
|
||||
let reply = json!({"channel_id": 7});
|
||||
let projected = parse_listen_reply(&reply).unwrap();
|
||||
assert_eq!(
|
||||
projected,
|
||||
TunnelListenReply {
|
||||
bound: None,
|
||||
listen: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_reply_tolerates_unknown_extra_fields() {
|
||||
// The additive posture — the reply viewer must NOT deny
|
||||
// unknown fields (future additive reply fields keep old
|
||||
// consumers working).
|
||||
let reply = json!({
|
||||
"channel_id": 7,
|
||||
"bound": {"path": "/run/x.sock"},
|
||||
"listen": 5,
|
||||
"future_field": {"nested": true}
|
||||
});
|
||||
let projected = parse_listen_reply(&reply).unwrap();
|
||||
assert_eq!(
|
||||
projected,
|
||||
TunnelListenReply {
|
||||
bound: Some(SubstrateAddr::Path {
|
||||
path: "/run/x.sock".to_string()
|
||||
}),
|
||||
listen: Some(5),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_reply_rejects_malformed_shapes() {
|
||||
// No channel_id at all — not an open reply.
|
||||
assert!(parse_listen_reply(&json!({"bound": {"path": "/p"}})).is_err());
|
||||
// channel_id = 0 (the wrapper never allocates 0 — channel 0 is
|
||||
// the call plane).
|
||||
assert!(parse_listen_reply(&json!({"channel_id": 0})).is_err());
|
||||
// Malformed bound (not substrate-shaped).
|
||||
let bad_bound = json!({"channel_id": 7, "bound": {"port": 1}});
|
||||
assert!(parse_listen_reply(&bad_bound).is_err());
|
||||
// Malformed listen (negative-shaped / not a u32 / zero).
|
||||
let bad_listen = json!({"channel_id": 7, "listen": 0});
|
||||
assert!(parse_listen_reply(&bad_listen).is_err());
|
||||
let bad_listen_type = json!({"channel_id": 7, "listen": "token"});
|
||||
assert!(parse_listen_reply(&bad_listen_type).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_spec_matches_wire_md() {
|
||||
let spec = tunnel_forwarded_spec();
|
||||
assert_eq!(spec.name, "channels/tunnel/forwarded");
|
||||
assert_eq!(spec.op_type, OperationType::Sub);
|
||||
assert_eq!(spec.visibility, Visibility::External);
|
||||
assert_eq!(
|
||||
spec.input_schema["properties"]["listen"]["minimum"],
|
||||
json!(1)
|
||||
);
|
||||
assert_eq!(
|
||||
spec.input_schema["properties"]["substrate"]["enum"],
|
||||
json!(["tcp", "udp", "unix"])
|
||||
);
|
||||
assert_eq!(
|
||||
spec.input_schema["required"],
|
||||
json!(["listen", "substrate", "peer"])
|
||||
);
|
||||
assert_eq!(spec.output_schema["properties"]["channel_id"]["minimum"], 1);
|
||||
assert_eq!(
|
||||
spec.access_control.required_scopes,
|
||||
vec![TUNNEL_FORWARDED_SCOPE.to_string()]
|
||||
);
|
||||
let channel_open = spec.channel_open.as_ref().expect("channel-open marker");
|
||||
assert_eq!(channel_open.alpn, TUNNEL_ALPN);
|
||||
assert!(spec.description.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_spec_peer_schema_gates_shape_per_substrate() {
|
||||
// The per-substrate peer sub-schema: the same if/then
|
||||
// structure the direct op pins its target with — structural
|
||||
// assertions per tunnels/params' precedent (no jsonschema dep
|
||||
// here).
|
||||
let schema = tunnel_forwarded_spec().input_schema;
|
||||
let allof = schema["allOf"].as_array().expect("allOf branches");
|
||||
assert_eq!(allof.len(), 2, "tcp/udp branch + unix branch");
|
||||
let tcpudp_then = &allof[0]["then"]["properties"]["peer"];
|
||||
let unix_then = &allof[1]["then"]["properties"]["peer"];
|
||||
assert_eq!(
|
||||
allof[0]["if"]["properties"]["substrate"]["enum"],
|
||||
json!(["tcp", "udp"])
|
||||
);
|
||||
assert_eq!(
|
||||
allof[1]["if"]["properties"]["substrate"]["const"],
|
||||
json!("unix")
|
||||
);
|
||||
assert_eq!(
|
||||
tcpudp_then["required"],
|
||||
json!(["host", "port"]),
|
||||
"tcp/udp peer requires host+port"
|
||||
);
|
||||
assert_eq!(tcpudp_then["properties"]["port"]["maximum"], json!(65535));
|
||||
assert_eq!(unix_then["required"], json!(["path"]));
|
||||
assert_eq!(
|
||||
tcpudp_then["additionalProperties"],
|
||||
json!(false),
|
||||
"cross-shape peer fields fail the schema"
|
||||
);
|
||||
assert_eq!(unix_then["additionalProperties"], json!(false));
|
||||
assert_eq!(allof[0]["then"]["properties"]["peer"]["type"], "object");
|
||||
assert_eq!(allof[1]["then"]["properties"]["peer"]["type"], "object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_params_parse_backstops_the_schema() {
|
||||
// The serde parse is the backstop for the shape it can use:
|
||||
// a peer that schema-gates passed but parse-time mismatches
|
||||
// (or a `listen: 0` riding a registry without the schema gate)
|
||||
// fails loud at the deserializer. `deny_unknown_fields` holds:
|
||||
// unknown top-level params reject; the untagged peer is
|
||||
// lenient about extra peer fields (same documented asymmetry
|
||||
// as the direct op's target — the SCHEMA is the shape-vs-
|
||||
// substrate gate).
|
||||
let cross_shape = json!({
|
||||
"listen": 1,
|
||||
"substrate": "unix",
|
||||
"peer": {"path": "/p", "host": "h"}
|
||||
});
|
||||
let parsed: TunnelForwardedParams = serde_json::from_value(cross_shape).unwrap();
|
||||
assert_eq!(
|
||||
parsed.peer,
|
||||
SubstrateAddr::Path {
|
||||
path: "/p".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// --- listen_reply_fields (the lenient session-surface projection) ----
|
||||
|
||||
#[test]
|
||||
fn listen_reply_fields_full_projection() {
|
||||
let reply = json!({
|
||||
"channel_id": 7,
|
||||
"bound": {"host": "203.0.113.9", "port": 42113},
|
||||
"listen": 918273
|
||||
});
|
||||
assert_eq!(
|
||||
listen_reply_fields(&reply),
|
||||
TunnelListenReply {
|
||||
bound: Some(SubstrateAddr::HostPort {
|
||||
host: "203.0.113.9".to_string(),
|
||||
port: 42113,
|
||||
}),
|
||||
listen: Some(918273),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_reply_fields_bare_reply_is_both_none() {
|
||||
// The dial/accept-first posture: the establisher supplied
|
||||
// nothing.
|
||||
let reply = json!({"channel_id": 7});
|
||||
assert_eq!(
|
||||
listen_reply_fields(&reply),
|
||||
TunnelListenReply {
|
||||
bound: None,
|
||||
listen: None
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_reply_fields_tolerates_unknown_extra_fields() {
|
||||
// The additive posture — the lenient projection must NOT gate
|
||||
// unknown reply fields.
|
||||
let reply = json!({
|
||||
"channel_id": 7,
|
||||
"bound": {"path": "/run/x.sock"},
|
||||
"listen": 5,
|
||||
"future_field": true
|
||||
});
|
||||
let projected = listen_reply_fields(&reply);
|
||||
assert_eq!(projected.listen, Some(5));
|
||||
assert_eq!(
|
||||
projected.bound,
|
||||
Some(SubstrateAddr::Path {
|
||||
path: "/run/x.sock".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_reply_fields_projects_malformed_to_none() {
|
||||
// The lenient projection never refuses construction: a
|
||||
// malformed shape (no `channel_id`, a malformed `bound`) logs
|
||||
// loud and surfaces both-`None` (use `parse_listen_reply`
|
||||
// directly for the loud typed view).
|
||||
let not_an_open_reply = json!({"bound": {"path": "/p"}});
|
||||
assert_eq!(
|
||||
listen_reply_fields(¬_an_open_reply),
|
||||
TunnelListenReply {
|
||||
bound: None,
|
||||
listen: None
|
||||
}
|
||||
);
|
||||
let malformed_bound = json!({"channel_id": 7, "bound": {"port": 1}});
|
||||
assert_eq!(
|
||||
listen_reply_fields(&malformed_bound),
|
||||
TunnelListenReply {
|
||||
bound: None,
|
||||
listen: None
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+944
-5
@@ -21,8 +21,17 @@
|
||||
//!
|
||||
//! The listen establisher variant (the producer-side listener — the
|
||||
//! `-R` far-side listener shape) is [`listen_establisher`] +
|
||||
//! [`AcceptQueue`] below (the `tunnels/producer-listen` shape).
|
||||
//! [`AcceptQueue`] below (the `tunnels/producer-listen` shape). The
|
||||
//! direct op's establisher ([`direct_establisher`] — dynamic-target
|
||||
//! egress, ADR-007) is the dial shape minus the registry lookup.
|
||||
//!
|
||||
//! The bind-first shape's plan payload ([`BindFirstPlan`] — the opaque
|
||||
//! listener + the correlation token, ADR-008 as amended) is defined
|
||||
//! here; the establisher and accept dispatcher that consume it land
|
||||
//! with `tunnels/bindfirst-producer` (the wire-stable types pin
|
||||
//! first, same ordering `tunnels/params` used for the base op).
|
||||
|
||||
use std::any::Any;
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::channels::operations::{
|
||||
@@ -30,14 +39,19 @@ use alkcall::channels::operations::{
|
||||
};
|
||||
use alkcall::channels::pump::pump_bidi;
|
||||
use alkcall::core::auth::AuthContext;
|
||||
use alkcall::core::types::Connection;
|
||||
use alkcall::core::types::{BiStream, Connection};
|
||||
use alkcall::registry::registration::OperationRegistry;
|
||||
use serde_json::Value;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};
|
||||
|
||||
use crate::params::{Substrate, TunnelParams};
|
||||
use crate::params::{
|
||||
Substrate, SubstrateAddr, TunnelDirectParams, TunnelForwardedParams, TunnelParams,
|
||||
};
|
||||
|
||||
pub use crate::params::{OP_TUNNEL_OPEN, TUNNEL_ALPN, TUNNEL_OPEN_SCOPE};
|
||||
pub use crate::params::{
|
||||
OP_TUNNEL_DIRECT, OP_TUNNEL_OPEN, TUNNEL_ALPN, TUNNEL_DIRECT_SCOPE, TUNNEL_FORWARDED_SCOPE,
|
||||
TUNNEL_OPEN_SCOPE,
|
||||
};
|
||||
|
||||
/// The dialed target handle — the plan payload (typed-opaque
|
||||
/// `ChannelPlan`; establisher and handler agree on the concrete
|
||||
@@ -57,6 +71,59 @@ impl std::fmt::Debug for TargetHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bind-first establisher's plan payload (ADR-008 §1 as amended):
|
||||
/// the assembly's listener handle + the correlation token + the listen
|
||||
/// tunnel's substrate.
|
||||
///
|
||||
/// **The listener is NOT `TargetHandle` halves** — a listener is
|
||||
/// accepted FROM, not pumped. The plan carries whatever the assembly's
|
||||
/// `BindFn` produced, typed-opaque ([`Any`]; the same
|
||||
/// injection pattern as `DialFn`/`AcceptFn` — the accept dispatcher
|
||||
/// downcasts it to the assembly's listener type via a type-erased
|
||||
/// accept closure; the ADR's "listener handle in the plan" is
|
||||
/// assembly-typed, and the protocol sees only this opaque pair).
|
||||
///
|
||||
/// **The correlation token (ADR-008 Amendment 1):** minted by the
|
||||
/// bind-first establisher — a fresh u32 per open, unique per producer
|
||||
/// (an `AtomicU32` counter seeded non-zero, e.g. 1, inside the
|
||||
/// establisher's closure state; wraps are acceptable — u32, and a wrap
|
||||
/// collision requires 2^32 concurrent opens on one producer). Carried
|
||||
/// twice: in the plan (the accept dispatcher stamps it into every
|
||||
/// forwarded open it opens for this listener) and as the additive
|
||||
/// `listen` reply field (the opener correlates its waiting session on
|
||||
/// it). It is NOT the listen channel's ID — the serving-side handler
|
||||
/// never sees its own channel ID, and generic hub relays forward
|
||||
/// params untouched (Amendment 1's rationale). Per-open fresh,
|
||||
/// per-producer unique; retired with the listener session.
|
||||
pub struct BindFirstPlan {
|
||||
/// The assembly-typed listener handle (e.g. a `TcpListener`
|
||||
/// wrapper the `BindFn` produced). Opaque to the protocol — the
|
||||
/// accept dispatcher downcasts via a type-erased accept closure.
|
||||
pub listener: Arc<dyn Any + Send + Sync>,
|
||||
/// The correlation token (see above): minted fresh per open,
|
||||
/// unique per producer, stamped into each forwarded open's
|
||||
/// `listen` param.
|
||||
pub token: u32,
|
||||
/// The listen tunnel's substrate (ADR-008 §3), stamped here at
|
||||
/// establisher time (the establisher already parsed the params
|
||||
/// successfully — one parse, one source of truth). The accept
|
||||
/// dispatcher reads it from the plan it already downcast; it must
|
||||
/// NOT re-parse wire input (a silent `Tcp` fallback on a parse
|
||||
/// failure would mismatch a UDP listen tunnel's data-plane
|
||||
/// framing — review 002 M-1).
|
||||
pub substrate: Substrate,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BindFirstPlan {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BindFirstPlan")
|
||||
.field("listener", &"<opaque>")
|
||||
.field("token", &self.token)
|
||||
.field("substrate", &self.substrate)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The typed establishment-failure vocabulary (ADR-049 §3's reason
|
||||
/// codes as this crate's establisher errors; mapped 1:1 onto
|
||||
/// `EstablishmentError`).
|
||||
@@ -189,6 +256,57 @@ fn parse_params(input: &Value) -> Result<TunnelParams, TunnelEstablishError> {
|
||||
.map_err(|e| TunnelEstablishError::HandlerError(format!("invalid params: {e}")))
|
||||
}
|
||||
|
||||
/// The direct establisher (ADR-007 §4 — the dial shape minus the
|
||||
/// registry lookup): parse `TunnelDirectParams`, render the target to
|
||||
/// the same backing-string form the registry backings use
|
||||
/// (`"host:port"` for tcp/udp, the path for unix), and call the
|
||||
/// injected [`DialFn`] — the SAME dial function the base op uses, so
|
||||
/// a producer that registered `dial_tcp` for the base op registers
|
||||
/// the direct op with zero new substrate code (the UDP framed-adapter
|
||||
/// wrap, ADR-003, happens inside the dial closure — the establisher
|
||||
/// stays substrate-agnostic). No registry lookup — `unknown_resource`
|
||||
/// can never fire.
|
||||
///
|
||||
/// The per-call `auth` carries the opener identity (CF-006); record it
|
||||
/// into `identity_witness` when provided (the direct op is the
|
||||
/// arbitrary-egress capability — identity visibility matters more,
|
||||
/// not less).
|
||||
///
|
||||
/// Error mapping (ADR-007 §4's table minus the registry row): the
|
||||
/// serde-parse failure of a schema-valid-shaped target maps
|
||||
/// [`TunnelEstablishError::HandlerError`] (the schema gate should
|
||||
/// have caught it — the parse is the backstop); dial failures pass
|
||||
/// through the `DialFn`'s own typed errors unchanged (`dial_failed` /
|
||||
/// `resource_shortage` / `handler_error`).
|
||||
pub fn direct_establisher(dial: DialFn) -> OpenEstablisher {
|
||||
direct_establisher_with_witness(dial, None)
|
||||
}
|
||||
|
||||
/// [`direct_establisher`] with an optional identity witness (the test
|
||||
/// seam for the CF-006 contract, same as the base op's).
|
||||
pub fn direct_establisher_with_witness(
|
||||
dial: DialFn,
|
||||
identity_witness: Option<IdentityWitness>,
|
||||
) -> OpenEstablisher {
|
||||
Arc::new(move |input: Value, auth| {
|
||||
let dial = Arc::clone(&dial);
|
||||
let witness = identity_witness.clone();
|
||||
Box::pin(async move {
|
||||
if let Some(w) = &witness {
|
||||
*w.lock().await = auth.identity.as_ref().map(|i| i.id.clone());
|
||||
}
|
||||
let params: TunnelDirectParams =
|
||||
serde_json::from_value(input.clone()).map_err(|e| {
|
||||
TunnelEstablishError::HandlerError(format!("invalid direct params: {e}"))
|
||||
})?;
|
||||
let rendered = params.render_target();
|
||||
let substrate = params.substrate;
|
||||
let dialed: TargetHandle = dial(substrate, &rendered).await?;
|
||||
Ok(Establishment::new(Arc::new(dialed) as ChannelPlan))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The listen establisher (shape 2 — producer.md §The Establisher):
|
||||
/// the same open op, same params, same typed errors; the establisher
|
||||
/// pops the next accepted connection from the injected [`AcceptFn`]
|
||||
@@ -381,6 +499,33 @@ pub fn register_tunnel_openable(
|
||||
)
|
||||
}
|
||||
|
||||
/// Register the direct op (`channels/tunnel/direct`, ADR-007) on a
|
||||
/// `ChannelCore` + the session's dispatch registry: the direct spec +
|
||||
/// [`direct_establisher_with_witness`] + the SAME pump handler as the
|
||||
/// base op (the direct op's plan payload is a [`TargetHandle`] exactly
|
||||
/// like the base op's; plan-flow is unchanged), via
|
||||
/// `register_openable_with_establisher` (`timeout: None` = the 10s
|
||||
/// default bound). `dial` is the SAME [`DialFn`] the base op uses —
|
||||
/// no new substrate code. `identity_witness` is the optional CF-006
|
||||
/// probe seam (the establisher records the per-call opener identity
|
||||
/// it saw).
|
||||
pub fn register_tunnel_direct_openable(
|
||||
core: &ChannelCore,
|
||||
on_registry: &Arc<OperationRegistry>,
|
||||
auth: AuthContext,
|
||||
dial: DialFn,
|
||||
identity_witness: Option<IdentityWitness>,
|
||||
) -> Result<(), String> {
|
||||
core.register_openable_with_establisher(
|
||||
crate::params::tunnel_direct_spec(),
|
||||
Some(direct_establisher_with_witness(dial, identity_witness)),
|
||||
make_tunnel_pump_handler(),
|
||||
on_registry,
|
||||
auth,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Register the tunnel open op with a LISTEN establisher (shape 2):
|
||||
/// the same spec + pump handler as [`register_tunnel_openable`]; the
|
||||
/// establisher pops accepted handles from the injected [`AcceptFn`]
|
||||
@@ -405,3 +550,797 @@ pub fn register_tunnel_listen_openable(
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Bind-first (shape 3, ADR-008) ---------------------------------------
|
||||
|
||||
/// The substrate bind function the assembly layer injects (shape 3 —
|
||||
/// ADR-008 §1): given a substrate + a registry backing (the same
|
||||
/// backing-string form the dial shape's `DialFn` receives), **bind the
|
||||
/// listener** and produce it. The protocol never binds (OQ-TN-04) —
|
||||
/// this closure is the platform boundary, the same inversion point as
|
||||
/// `DialFn`. The returned [`BoundListener`] pairs the opaque
|
||||
/// assembly-typed listener (the accept loop's business) with the
|
||||
/// OS-chosen bound address (the `bound` reply field's source).
|
||||
pub type BindFn = Arc<
|
||||
dyn Fn(
|
||||
Substrate,
|
||||
&str,
|
||||
)
|
||||
-> futures::future::BoxFuture<'static, Result<BoundListener, TunnelEstablishError>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// What the assembly's bind produces (ADR-008 §1): the opaque listener
|
||||
/// (the accept loop's business) + the OS-chosen bound address (the
|
||||
/// `bound` reply field's source — the REAL resolved address, port `0`
|
||||
/// resolved; informational surface, never addressing).
|
||||
pub struct BoundListener {
|
||||
/// The assembly-typed listener (e.g. the `TcpListener` wrapper the
|
||||
/// assembly chose). Opaque to the protocol — rides the plan's
|
||||
/// `Any`, downcast only inside the assembly's accept closure.
|
||||
pub listener: Arc<dyn Any + Send + Sync>,
|
||||
/// The real OS-chosen bound address (the `bound` reply field's
|
||||
/// source).
|
||||
pub bound: SubstrateAddr,
|
||||
}
|
||||
|
||||
/// The token mint (the discipline pinned on [`BindFirstPlan`]): a
|
||||
/// non-zero-seeded `AtomicU32` counter held in the establisher's
|
||||
/// closure state — fresh per open, unique per producer, wraps
|
||||
/// acceptable (a wrap collision requires 2^32 concurrent opens on one
|
||||
/// producer — and that bound is loose: the token is
|
||||
/// per-producer-CONNECTION and each open rides one connection, so the
|
||||
/// alkcall 256-channels-per-connection cap makes a live-collision
|
||||
/// window unreachable by a wide margin, review 002 N-5). The counter
|
||||
/// seeds at 1: `fetch_add` returns the
|
||||
/// PREVIOUS value, so the first mint is 1 and the minted stream is
|
||||
/// 1, 2, 3, … — token 0 (an illegal wire value; the forwarded params'
|
||||
/// `listen` pin rejects it) is never minted, and a u32 wrap that lands
|
||||
/// on 0 re-increments (the loop skips it; the 2^32 collision window
|
||||
/// makes wrap uniqueness a non-issue at the discipline's own terms).
|
||||
fn mint_token(counter: &std::sync::atomic::AtomicU32) -> u32 {
|
||||
use std::sync::atomic::Ordering;
|
||||
loop {
|
||||
let prev = counter.fetch_add(1, Ordering::Relaxed);
|
||||
if prev != 0 {
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bind-first establisher (shape 3 — producer.md §The Establisher,
|
||||
/// ADR-008 §1): the same open op, same params, same typed-error table;
|
||||
/// the establisher **binds** (via the injected [`BindFn`]) instead of
|
||||
/// dialing or popping an accept, mints the correlation token
|
||||
/// (per-open-fresh, per-producer-unique — the discipline pinned on
|
||||
/// [`BindFirstPlan`]), contributes
|
||||
/// the `bound` + `listen` reply fields (the establisher → reply
|
||||
/// projection, ADR-049 amendment 3 — the wrapper merges them into the
|
||||
/// success output after its reserved `channel_id` key), and returns
|
||||
/// the [`BindFirstPlan`] as the plan payload.
|
||||
///
|
||||
/// The registry check is a namespace gate only (the same posture as
|
||||
/// [`listen_establisher`]): the bind-first producer's "backing" is the
|
||||
/// [`BindFn`], not a dial target; a resource the producer does not
|
||||
/// produce is still `unknown_resource`. The resolved backing string
|
||||
/// rides the same signature the dial shape's `DialFn` takes — the bind
|
||||
/// closure sees the same `(substrate, backing)` form.
|
||||
///
|
||||
/// Error mapping (producer.md's shape-3 table): unbindable/held port →
|
||||
/// `dial_failed`-class (the bind IS the dial here); bind resource
|
||||
/// exhaustion → `resource_shortage`; registry miss →
|
||||
/// `unknown_resource`; internal → `handler_error`. The `BindFn`
|
||||
/// returns the typed errors; the establisher passes them through.
|
||||
///
|
||||
/// The token lives in BOTH the reply (`listen` — the opener's
|
||||
/// correlation key) and the plan (the accept dispatcher stamps it into
|
||||
/// every forwarded open). Two opens mint distinct tokens (the mint is
|
||||
/// per-open); each dispatcher stamps ITS OWN plan's token (the plan
|
||||
/// flows per-open — R-01).
|
||||
pub fn bindfirst_establisher(registry: ResourceRegistry, bind: BindFn) -> OpenEstablisher {
|
||||
use std::sync::atomic::AtomicU32;
|
||||
let counter = Arc::new(AtomicU32::new(1));
|
||||
Arc::new(move |input: Value, _auth| {
|
||||
let registry = registry.clone();
|
||||
let bind = Arc::clone(&bind);
|
||||
let counter = Arc::clone(&counter);
|
||||
Box::pin(async move {
|
||||
let params: TunnelParams = parse_params(&input)?;
|
||||
let backing = registry
|
||||
.lookup(params.substrate, ¶ms.resource)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
TunnelEstablishError::UnknownResource(format!(
|
||||
"resource `{}` ({:?}) not produced by this producer",
|
||||
params.resource, params.substrate
|
||||
))
|
||||
})?;
|
||||
let token = mint_token(&counter);
|
||||
let BoundListener { listener, bound } = bind(params.substrate, &backing).await?;
|
||||
let plan = Arc::new(BindFirstPlan {
|
||||
listener,
|
||||
token,
|
||||
substrate: params.substrate,
|
||||
}) as ChannelPlan;
|
||||
let bound = serde_json::to_value(&bound)
|
||||
.map_err(|e| TunnelEstablishError::HandlerError(format!("bound serialize: {e}")))?;
|
||||
Ok(Establishment::new(plan)
|
||||
.with_reply_field("bound", bound)
|
||||
.with_reply_field("listen", serde_json::json!(token)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The accepted-connection source the assembly layer injects for the
|
||||
/// BIND-FIRST producer (shape 3 — ADR-008 §1): pops one accepted
|
||||
/// connection from the plan's opaque listener and yields
|
||||
/// `(SubstrateAddr /* peer */, TargetHandle /* accepted halves */)`.
|
||||
/// The closure receives the listener as the opaque `Any` — the
|
||||
/// assembly downcasts it to ITS concrete listener type inside the
|
||||
/// closure (the same type-erased injection pattern as
|
||||
/// `DialFn`/`AcceptFn`; substrate code lives behind the closure,
|
||||
/// ADR-004). Substrate code lives behind the closure (ADR-004); the
|
||||
/// protocol crate never sees a socket type.
|
||||
///
|
||||
/// A returned `Err` is a LOOP-CRITICAL condition: the accept loop's
|
||||
/// substrate source is broken (the listener closed, accept errored
|
||||
/// unrecoverably) — the dispatcher ends the loop, tears the listener
|
||||
/// down with the plan, and lets the handler task finish (the channel
|
||||
/// reaps). Per-ACCEPT failures (forwarded-open failures) do NOT come
|
||||
/// from this closure — see `make_bindfirst_accept_handler`'s error
|
||||
/// posture.
|
||||
pub type BindFirstAcceptLoop = Arc<
|
||||
dyn Fn(
|
||||
Arc<dyn Any + Send + Sync>,
|
||||
) -> futures::future::BoxFuture<
|
||||
'static,
|
||||
Result<(SubstrateAddr, TargetHandle), TunnelEstablishError>,
|
||||
> + Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// The per-accept forwarded-open caller (ADR-008 §3): opens
|
||||
/// `OP_TUNNEL_FORWARDED` with `TunnelForwardedParams {listen,
|
||||
/// substrate, peer}` on the channel-0 call plane toward the listen
|
||||
/// opener, adopts the opener-allocated ID on the producer-side
|
||||
/// manager, and returns the adopted channel's halves (the pump side).
|
||||
///
|
||||
/// **This closure is the call plane** (the design pin — producer.md
|
||||
/// §The Pump Handler item 5): the per-channel `Connection` the
|
||||
/// dispatcher receives is stream-less (`open_bi` is `StreamClosed` —
|
||||
/// ADR-035) and carries no call surface, so the registration closes
|
||||
/// over the serving session's `Arc<CallConnection>` (the assembly has
|
||||
/// it — it constructed the serving session). The shape is the reverse
|
||||
/// POC's `open_reverse_channel`: `call_with_payload` on channel 0 →
|
||||
/// extract `channel_id` from the reply → `manager.adopt_channel`. The
|
||||
/// accepted halves pump against the adopted channel's halves.
|
||||
pub type ForwardedOpener = Arc<
|
||||
dyn Fn(
|
||||
TunnelForwardedParams,
|
||||
) -> futures::future::BoxFuture<
|
||||
'static,
|
||||
Result<
|
||||
(
|
||||
alkcall::channels::reassembly::MpscSendStream,
|
||||
alkcall::channels::reassembly::MpscRecvStream,
|
||||
),
|
||||
TunnelEstablishError,
|
||||
>,
|
||||
> + Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// The bind-first pump handler — the ACCEPT DISPATCHER (ADR-008 §1).
|
||||
///
|
||||
/// Handler contract (producer.md §The Pump Handler item 5 + R-02):
|
||||
///
|
||||
/// 1. `conn.accept_bi()` FIRST (the yield-once contract — the
|
||||
/// wrapper's accepted-flag telemetry requires it), downcast the
|
||||
/// plan to [`BindFirstPlan`].
|
||||
/// 2. The listen channel's own `BiStream` stays idle (no pumps on
|
||||
/// it); **its EOF cancels the loop and tears the listener down** —
|
||||
/// a 1-byte `read` on the idle stream observes the opener dropping
|
||||
/// the listener session (the zero-byte EOF sentinel; ADR-050's
|
||||
/// teardown contract). `select!` races the EOF watch against the
|
||||
/// accept loop.
|
||||
/// 3. Per accept: open the forwarded channel ([`ForwardedOpener`] —
|
||||
/// the call plane, `{listen: token, substrate, peer}` params),
|
||||
/// then `pump_bidi` the accepted halves against the adopted
|
||||
/// channel inside a spawned task tracked in the handler's handle
|
||||
/// set. A forwarded-open failure is per-accept isolated: log and
|
||||
/// drop that accepted connection — the loop continues (the
|
||||
/// listener's overall health is not hostage to one failed accept;
|
||||
/// v1: no retry/backoff). A loop-critical accept error ([`BindFirstAcceptLoop`]'s
|
||||
/// contract) ends the loop.
|
||||
/// 4. **Lifetime (R-02):** the handler's returned `JoinHandle` tracks
|
||||
/// the whole accept loop + every per-accept pump it spawned — the
|
||||
/// handler task awaits the pump handles on loop exit (a forwarded
|
||||
/// pump outliving its listener is a leak). Handler exit → wrapper
|
||||
/// teardown (the listen channel reaps) → the far listener tears
|
||||
/// down (the plan's listener drops with the loop).
|
||||
///
|
||||
/// The substrate discriminator for the forwarded opens rides the
|
||||
/// plan ([`BindFirstPlan`].substrate — stamped at establisher time
|
||||
/// from the one successful `TunnelParams` parse; the listen tunnel's
|
||||
/// substrate; ADR-008 §3 "the listen tunnel's substrate shapes the
|
||||
/// forwarded channel's data-plane framing"). The dispatcher never
|
||||
/// re-parses wire input — no fallback branch exists.
|
||||
pub fn make_bindfirst_accept_handler(
|
||||
accept_loop: BindFirstAcceptLoop,
|
||||
open_forwarded: ForwardedOpener,
|
||||
) -> OpenHandler {
|
||||
Arc::new(
|
||||
move |_input: Value, plan: Option<ChannelPlan>, conn: Connection, _auth: AuthContext| {
|
||||
let accept_loop = Arc::clone(&accept_loop);
|
||||
let open_forwarded = Arc::clone(&open_forwarded);
|
||||
tokio::spawn(async move {
|
||||
let Some(bidi) = conn.accept_bi().await.ok() else {
|
||||
tracing::error!("tunnel bind-first: accept_bi failed");
|
||||
return;
|
||||
};
|
||||
let Some(plan) = plan.and_then(|p| p.downcast::<BindFirstPlan>().ok()) else {
|
||||
tracing::error!("tunnel bind-first: no BindFirstPlan in plan");
|
||||
return;
|
||||
};
|
||||
// The substrate rides the plan (stamped at establisher
|
||||
// time — one parse, one source of truth): the
|
||||
// dispatcher must not re-parse wire input (a parse
|
||||
// failure here would have nowhere loud to go; review
|
||||
// 002 M-1 removed the silent `Tcp` fallback instead).
|
||||
let substrate = plan.substrate;
|
||||
let (mut idle_read, _idle_write) = tokio::io::split(bidi);
|
||||
|
||||
// The per-accept pump task set — tracked by the
|
||||
// handler's JoinHandle (R-02: every pump the handler
|
||||
// spawned is awaited on loop exit).
|
||||
let mut pumps: Vec<tokio::task::JoinHandle<(u64, u64)>> = Vec::new();
|
||||
|
||||
let loop_result = loop {
|
||||
// The idle-stream EOF watch vs the accept loop
|
||||
// (select!): the opener dropping the listener
|
||||
// session EOFs the listen channel's stream — the
|
||||
// zero-byte sentinel — and cancels the loop.
|
||||
let eof_watch = async {
|
||||
// The 1-byte read on the idle stream: `Ok(0)`
|
||||
// (or an error) = the opener dropped the
|
||||
// session; `Ok(1..)` never happens on a well-
|
||||
// behaved opener (the stream is idle — a peer
|
||||
// that writes is misbehaving; treat any
|
||||
// non-empty read as an EOF trigger too — the
|
||||
// channel is not a data plane).
|
||||
let mut byte = [0u8; 1];
|
||||
match idle_read.read(&mut byte).await {
|
||||
Ok(0) | Err(_) => true,
|
||||
Ok(_) => true,
|
||||
}
|
||||
};
|
||||
let accept = accept_loop(Arc::clone(&plan.listener));
|
||||
tokio::select! {
|
||||
_ = eof_watch => break "eof",
|
||||
accepted = accept => match accepted {
|
||||
Ok((peer, handle)) => {
|
||||
let forwarded = TunnelForwardedParams {
|
||||
listen: plan.token,
|
||||
substrate,
|
||||
peer: peer.clone(),
|
||||
};
|
||||
match open_forwarded(forwarded).await {
|
||||
Ok((send, recv)) => {
|
||||
let channel_bidi =
|
||||
alkcall::core::types::BiStream::from_joined(recv, send);
|
||||
pumps.push(tokio::spawn(pump_bidi(
|
||||
channel_bidi,
|
||||
handle.read,
|
||||
handle.write,
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
// Per-accept isolation: the
|
||||
// forwarded open failed (scope-
|
||||
// less opener → FORBIDDEN; old
|
||||
// opener → NOT_FOUND; correlation
|
||||
// miss → handler_error-class) —
|
||||
// drop this accepted connection,
|
||||
// keep serving. v1: no retry/
|
||||
// backoff (documented posture).
|
||||
tracing::warn!(
|
||||
listen = plan.token,
|
||||
error = %e,
|
||||
"tunnel bind-first: forwarded open failed; dropping the accepted connection"
|
||||
);
|
||||
drop(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Loop-critical (the listener's
|
||||
// substrate source broke): end the
|
||||
// loop — the listener tears down with
|
||||
// the plan.
|
||||
tracing::warn!(
|
||||
listen = plan.token,
|
||||
error = %e,
|
||||
"tunnel bind-first: accept loop ended; tearing the listener down"
|
||||
);
|
||||
break "accept-error";
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
tracing::debug!(
|
||||
listen = plan.token,
|
||||
reason = loop_result,
|
||||
"tunnel bind-first: accept loop exited"
|
||||
);
|
||||
// The listener drops with the plan (the last Arc) — the
|
||||
// far listener tears down.
|
||||
drop(plan);
|
||||
// R-02: the handler's JoinHandle tracks every per-accept
|
||||
// pump it spawned — await them on loop exit (a forwarded
|
||||
// pump outliving its listener is a leak).
|
||||
for pump in pumps.drain(..) {
|
||||
let _ = pump.await;
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The bind-first registration's injected seams (the assembly layer
|
||||
/// owns all four; bundling keeps the registration's signature to the
|
||||
/// core pair + the bundle — the seams are one coherent injection
|
||||
/// point, the same posture as `DialFn` riding the dial registration):
|
||||
/// - `bind` — the substrate bind function ([`BindFn`]; the protocol
|
||||
/// never binds, OQ-TN-04).
|
||||
/// - `accept_loop` — the per-listener accept loop
|
||||
/// ([`BindFirstAcceptLoop`]; receives the opaque listener, yields
|
||||
/// `(peer, halves)` per accept).
|
||||
/// - `open_forwarded` — the forwarded-open caller ([`ForwardedOpener`];
|
||||
/// the call plane: the serving session's `Arc<CallConnection>` +
|
||||
/// the producer-side `ChannelManager` for the adopt — the assembly
|
||||
/// has both, it constructed the serving session; the reverse POC's
|
||||
/// `open_reverse_channel` shape).
|
||||
pub struct BindFirstInjections {
|
||||
/// The substrate bind function (the platform boundary).
|
||||
pub bind: BindFn,
|
||||
/// The per-listener accept loop (pops the opaque listener).
|
||||
pub accept_loop: BindFirstAcceptLoop,
|
||||
/// The forwarded-open caller (the call plane + the adopt).
|
||||
pub open_forwarded: ForwardedOpener,
|
||||
}
|
||||
|
||||
/// Register the bind-first openable (shape 3, ADR-008 §1): the SAME
|
||||
/// spec + scope gate (`tunnel:open`) as the dial/listen variants — the
|
||||
/// bind-first shape is a per-registration establisher choice over the
|
||||
/// same op (ADR-008 §5's per-registration posture) — with
|
||||
/// [`bindfirst_establisher`] as the establisher and
|
||||
/// [`make_bindfirst_accept_handler`] as the pump. One establisher per
|
||||
/// op id per registry (same note as
|
||||
/// `register_tunnel_listen_openable`).
|
||||
///
|
||||
/// `identity_witness` is the optional CF-006 probe seam (the
|
||||
/// establisher records the per-call opener identity it saw).
|
||||
pub fn register_tunnel_bindfirst_openable(
|
||||
core: &ChannelCore,
|
||||
registry: &ResourceRegistry,
|
||||
on_registry: &Arc<OperationRegistry>,
|
||||
auth: AuthContext,
|
||||
injections: BindFirstInjections,
|
||||
identity_witness: Option<IdentityWitness>,
|
||||
) -> Result<(), String> {
|
||||
let BindFirstInjections {
|
||||
bind,
|
||||
accept_loop,
|
||||
open_forwarded,
|
||||
} = injections;
|
||||
let establisher = bindfirst_establisher(registry.clone(), bind);
|
||||
let establisher = match identity_witness {
|
||||
Some(witness) => witness_establisher(establisher, witness),
|
||||
None => establisher,
|
||||
};
|
||||
core.register_openable_with_establisher(
|
||||
crate::params::tunnel_open_spec(),
|
||||
Some(establisher),
|
||||
make_bindfirst_accept_handler(accept_loop, open_forwarded),
|
||||
on_registry,
|
||||
auth,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`tunnel_establisher_with_witness`]-shaped CF-006 probe for the
|
||||
/// bind-first establisher: records the per-call opener identity into
|
||||
/// the witness, then delegates to the inner establisher (the same
|
||||
/// seam the dial/direct variants inline).
|
||||
fn witness_establisher(
|
||||
inner: OpenEstablisher,
|
||||
identity_witness: IdentityWitness,
|
||||
) -> OpenEstablisher {
|
||||
Arc::new(move |input: Value, auth| {
|
||||
let inner = Arc::clone(&inner);
|
||||
let witness = Arc::clone(&identity_witness);
|
||||
Box::pin(async move {
|
||||
*witness.lock().await = auth.identity.as_ref().map(|i| i.id.clone());
|
||||
inner(input, auth).await
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// --- The forwarded op's SERVING side (the opener half, ADR-008 §3) --------
|
||||
|
||||
/// The correlation material the opener-side handler yields per
|
||||
/// forwarded open: the parsed params + the channel's `BiStream`. The
|
||||
/// ASSEMBLY correlates (token → its waiting listener session
|
||||
/// bookkeeping) and owns the pump — it awaits the returned
|
||||
/// `JoinHandle` inline (R-02: the forwarded channel's lifetime tracks
|
||||
/// it; early return = teardown-at-birth reaps the channel), because
|
||||
/// the local halves are assembly state (the SOCKS5 BIND client's
|
||||
/// socket) that only the assembly knows. The protocol provides the op
|
||||
/// and params, not the wait state machine (producer.md §The Forwarded
|
||||
/// Op; ADR-008 §3).
|
||||
pub type ForwardedHandler =
|
||||
Arc<dyn Fn(TunnelForwardedParams, BiStream) -> tokio::task::JoinHandle<()> + Send + Sync>;
|
||||
|
||||
/// The forwarded op's opener-side pump handler (ADR-008 §3, the
|
||||
/// no-establisher serving shape): parse `TunnelForwardedParams`
|
||||
/// (`deny_unknown_fields` + the positive-`listen` serde pin — a
|
||||
/// malformed open fails the parse LOUD; the wrapper then returns early
|
||||
/// and the channel tears down at birth — no phantom channel, the
|
||||
/// correct shape for a malformed ingress), accept the channel's
|
||||
/// `BiStream` (yield-once; accepting BEFORE the assembly handler runs
|
||||
/// keeps the R-02 accepted-flag telemetry truthful), and hand the
|
||||
/// parsed params + the stream to the injected [`ForwardedHandler`].
|
||||
///
|
||||
/// **Parse-before-accept ordering (the R-02 telemetry note):** the
|
||||
/// params parse happens BEFORE `conn.accept_bi()`, so for a
|
||||
/// malformed-params open the R-02 accepted-flag telemetry reads
|
||||
/// `false` — by design. A malformed-params open IS a birth-teardown
|
||||
/// shape (the exact case the telemetry's birth-teardown hint exists
|
||||
/// for); accepting first would hold a stream open for a params set
|
||||
/// the producer already got wrong. The hint firing here is correct,
|
||||
/// not noise — future alkcall reviewers: do not flag the flag
|
||||
/// (review 002 N-3).
|
||||
///
|
||||
/// **Pump responsibility split (the honest-shape decision):** the
|
||||
/// injected handler receives the channel `BiStream` and owns the pump
|
||||
/// — `pump_bidi(forwarded_bidi, local_read, local_write)` — because
|
||||
/// the local halves are assembly state. The protocol-side built-in
|
||||
/// handler only bridges wrapper→assembly; it awaits the injected
|
||||
/// handler's `JoinHandle` inline (R-02 — the forwarded channel's
|
||||
/// lifetime tracks it, so a correlation miss or a dropped assembly
|
||||
/// handler tears the channel down).
|
||||
///
|
||||
/// **Correlation-miss semantics (documented + test-pinned):** a
|
||||
/// `listen` token with no matching waiting session is the ASSEMBLY's
|
||||
/// rejection (it holds the map): its handler's error path drops the
|
||||
/// stream and returns — teardown-at-birth reaps the forwarded channel.
|
||||
/// The producer's accept loop drops the accepted connection when that
|
||||
/// open fails (the shapes mirror — per-accept isolation,
|
||||
/// `tunnels/bindfirst-producer`'s posture).
|
||||
pub fn make_forwarded_op_handler(handler: ForwardedHandler) -> OpenHandler {
|
||||
Arc::new(
|
||||
move |input: Value, _plan: Option<ChannelPlan>, conn: Connection, _auth: AuthContext| {
|
||||
let handler = Arc::clone(&handler);
|
||||
tokio::spawn(async move {
|
||||
let forwarded: TunnelForwardedParams = match serde_json::from_value(input) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"tunnel forwarded: malformed params; tearing the channel down"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Ok(bidi) = conn.accept_bi().await else {
|
||||
tracing::error!("tunnel forwarded: accept_bi failed");
|
||||
return;
|
||||
};
|
||||
let pump = handler(forwarded, bidi);
|
||||
let _ = pump.await;
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Register the forwarded op's opener-side serving openable (ADR-008
|
||||
/// §3): `ChannelCore::register_openable` — **no establisher** (the
|
||||
/// forwarded
|
||||
/// channel needs no establishment; its data plane starts immediately)
|
||||
/// — scope-gated `tunnel:forwarded` (ingress, a separate grant from
|
||||
/// `tunnel:open`/`tunnel:direct`, never implied by either — ADR-006
|
||||
/// Amendment 1), via [`crate::params::tunnel_forwarded_spec`]. The
|
||||
/// wrapper's
|
||||
/// `channel_id` allocation IS the reply (the producer adopts it).
|
||||
///
|
||||
/// Identity (CF-005 precedence, upstream machinery): the opener-side
|
||||
/// gate sees the PRODUCER's per-call identity (the producer may carry
|
||||
/// an `auth_token` when it is itself forwarding); the `peer` param is
|
||||
/// producer-asserted metadata, not transport truth — the ACL trusts
|
||||
/// the grant, the application treats `peer` as metadata. Nothing to
|
||||
/// implement here (upstream's per-call overlay handles it); the test
|
||||
/// suite pins it.
|
||||
///
|
||||
/// Post-hoc registration is supported (W2) — the dispatcher reads
|
||||
/// through the shared `Arc<OperationRegistry>` per dispatch, the same
|
||||
/// posture as [`register_tunnel_openable`]. One registration per op id
|
||||
/// per registry; the op id is `channels/tunnel/forwarded` (distinct
|
||||
/// from the base op — a session may serve both).
|
||||
pub fn register_tunnel_forwarded_openable(
|
||||
core: &ChannelCore,
|
||||
on_registry: &Arc<OperationRegistry>,
|
||||
auth: AuthContext,
|
||||
handler: ForwardedHandler,
|
||||
) -> Result<(), String> {
|
||||
core.register_openable(
|
||||
crate::params::tunnel_forwarded_spec(),
|
||||
make_forwarded_op_handler(handler),
|
||||
on_registry,
|
||||
auth,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The plan-payload contract: `BindFirstPlan` rides the
|
||||
/// typed-opaque `ChannelPlan` (`Arc<dyn Any + Send + Sync>`) and
|
||||
/// the accept dispatcher recovers it by downcast — the same
|
||||
/// establisher/handler agreement `TargetHandle` pins. The opaque
|
||||
/// listener half downcasts to the assembly's concrete type.
|
||||
#[test]
|
||||
fn bindfirst_plan_downcasts_through_channel_plan() {
|
||||
struct TestListener(u32);
|
||||
let plan: ChannelPlan = Arc::new(BindFirstPlan {
|
||||
listener: Arc::new(TestListener(42)) as Arc<dyn Any + Send + Sync>,
|
||||
token: 918273,
|
||||
substrate: Substrate::Udp,
|
||||
});
|
||||
let unwrapped = plan
|
||||
.downcast::<BindFirstPlan>()
|
||||
.expect("BindFirstPlan plan");
|
||||
assert_eq!(unwrapped.token, 918273);
|
||||
assert_eq!(
|
||||
unwrapped.substrate,
|
||||
Substrate::Udp,
|
||||
"the plan's substrate rides it"
|
||||
);
|
||||
let listener = unwrapped
|
||||
.listener
|
||||
.downcast_ref::<TestListener>()
|
||||
.expect("assembly-typed listener");
|
||||
assert_eq!(listener.0, 42);
|
||||
// A wrong-type downcast fails (the opaque pair is not a
|
||||
// TargetHandle).
|
||||
let other: ChannelPlan = Arc::new(TargetHandle {
|
||||
read: Box::new(tokio::io::empty()),
|
||||
write: Box::new(tokio::io::sink()),
|
||||
});
|
||||
assert!(other.downcast_ref::<BindFirstPlan>().is_none());
|
||||
}
|
||||
|
||||
// --- bind-first establisher (shape 3, ADR-008) -----------------------
|
||||
|
||||
/// The mint discipline: the counter seeds at 1, the first mint is
|
||||
/// 1, the stream is per-open fresh + per-producer unique, and
|
||||
/// token 0 is never minted (the loop skips it — a wrap that lands
|
||||
/// on 0 re-increments).
|
||||
#[test]
|
||||
fn mint_token_seeds_nonzero_and_increments() {
|
||||
let counter = std::sync::atomic::AtomicU32::new(1);
|
||||
let first = mint_token(&counter);
|
||||
let second = mint_token(&counter);
|
||||
assert_eq!(first, 1, "a 1-seeded counter's first mint is 1");
|
||||
assert_eq!(second, 2);
|
||||
// Two mints never collide (per-open fresh).
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
|
||||
/// Token 0 is never minted: a counter that has wrapped around to 0
|
||||
/// re-increments (the loop skips the illegal wire value).
|
||||
#[test]
|
||||
fn mint_token_never_yields_zero() {
|
||||
// Force the wrap shape: seed the counter at u32::MAX — the
|
||||
// next fetch_add returns u32::MAX (legal), then wraps to 0,
|
||||
// which the loop must skip (the mint after the wrap is 1).
|
||||
let counter = std::sync::atomic::AtomicU32::new(u32::MAX);
|
||||
let before_wrap = mint_token(&counter);
|
||||
assert_eq!(before_wrap, u32::MAX);
|
||||
let after_wrap = mint_token(&counter);
|
||||
assert_eq!(after_wrap, 1, "the wrap lands on 0; the mint skips it");
|
||||
}
|
||||
|
||||
/// The establisher flow (registry gate → mint → bind → reply
|
||||
/// fields → plan), pinned at the `Establishment` surface: the plan
|
||||
/// is the `BindFirstPlan`, the reply fields are `bound` (the
|
||||
/// `BindFn`'s address) + `listen` (the minted token), and the
|
||||
/// token in the plan matches the token in the reply.
|
||||
#[tokio::test]
|
||||
async fn bindfirst_establisher_flow_end_to_end() {
|
||||
struct TestListener(u32);
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("listener", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let bind: BindFn = Arc::new(|_substrate, _backing| {
|
||||
Box::pin(async {
|
||||
Ok(BoundListener {
|
||||
listener: Arc::new(TestListener(7)) as Arc<dyn Any + Send + Sync>,
|
||||
bound: SubstrateAddr::HostPort {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 42113,
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
let establisher = bindfirst_establisher(registry, bind);
|
||||
let input = serde_json::json!({"resource": "listener", "substrate": "tcp"});
|
||||
let establishment = establisher(input, AuthContext::anonymous(b"alk/tunnel"))
|
||||
.await
|
||||
.expect("establishment");
|
||||
// The reply fields: `bound` (substrate-shaped, the BindFn's
|
||||
// address) + `listen` (the token).
|
||||
let fields = establishment.reply_fields().expect("reply fields");
|
||||
assert_eq!(
|
||||
fields.get("bound"),
|
||||
Some(&serde_json::json!({"host": "127.0.0.1", "port": 42113}))
|
||||
);
|
||||
let listen = fields
|
||||
.get("listen")
|
||||
.and_then(|v| v.as_u64())
|
||||
.expect("listen");
|
||||
assert!(listen > 0, "the minted token is positive");
|
||||
// The plan: the BindFirstPlan carries the SAME token + the
|
||||
// parsed substrate.
|
||||
let plan = establishment.plan.expect("plan");
|
||||
let plan = plan.downcast::<BindFirstPlan>().expect("BindFirstPlan");
|
||||
assert_eq!(plan.token as u64, listen, "plan token == reply token");
|
||||
assert_eq!(
|
||||
plan.substrate,
|
||||
Substrate::Tcp,
|
||||
"the plan stamps the parsed substrate"
|
||||
);
|
||||
let listener = plan
|
||||
.listener
|
||||
.downcast_ref::<TestListener>()
|
||||
.expect("listener");
|
||||
assert_eq!(listener.0, 7);
|
||||
}
|
||||
|
||||
/// The token discipline across TWO opens on the SAME producer: the
|
||||
/// establisher mints distinct tokens per open (the per-open-fresh
|
||||
/// half; the per-producer-unique half is the same counter).
|
||||
#[tokio::test]
|
||||
async fn bindfirst_establisher_mints_distinct_tokens_per_open() {
|
||||
struct TestListener;
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("listener", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let bind: BindFn = Arc::new(|_substrate, _backing| {
|
||||
Box::pin(async {
|
||||
Ok(BoundListener {
|
||||
listener: Arc::new(TestListener) as Arc<dyn Any + Send + Sync>,
|
||||
bound: SubstrateAddr::Path {
|
||||
path: "/tmp/x".to_string(),
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
let establisher = bindfirst_establisher(registry, bind);
|
||||
let input = serde_json::json!({"resource": "listener", "substrate": "tcp"});
|
||||
let e1 = establisher(input.clone(), AuthContext::anonymous(b"alk/tunnel"))
|
||||
.await
|
||||
.expect("open 1");
|
||||
let e2 = establisher(input, AuthContext::anonymous(b"alk/tunnel"))
|
||||
.await
|
||||
.expect("open 2");
|
||||
let t1 = e1
|
||||
.reply_fields()
|
||||
.and_then(|m| m.get("listen"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.expect("listen 1");
|
||||
let t2 = e2
|
||||
.reply_fields()
|
||||
.and_then(|m| m.get("listen"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.expect("listen 2");
|
||||
assert_ne!(t1, t2, "two opens mint distinct tokens");
|
||||
}
|
||||
|
||||
/// The registry miss → `unknown_resource` (the namespace gate —
|
||||
/// the same posture as the listen establisher).
|
||||
#[tokio::test]
|
||||
async fn bindfirst_establisher_registry_miss_is_unknown_resource() {
|
||||
let registry = ResourceRegistry::new();
|
||||
let bind: BindFn = Arc::new(|_substrate, _backing| {
|
||||
Box::pin(async {
|
||||
panic!("the bind closure must not run past the gate");
|
||||
})
|
||||
});
|
||||
let establisher = bindfirst_establisher(registry, bind);
|
||||
let input = serde_json::json!({"resource": "ghost", "substrate": "tcp"});
|
||||
let err = establisher(input, AuthContext::anonymous(b"alk/tunnel"))
|
||||
.await
|
||||
.expect_err("registry miss");
|
||||
assert!(matches!(err, EstablishmentError::UnknownResource { .. }));
|
||||
}
|
||||
|
||||
/// The `BindFn`'s typed errors pass through: a held port (the
|
||||
/// bind IS the dial) surfaces `dial_failed`; bind resource
|
||||
/// exhaustion surfaces `resource_shortage`.
|
||||
#[tokio::test]
|
||||
async fn bindfirst_establisher_bind_errors_pass_through() {
|
||||
for expected in ["dial_failed", "resource_shortage"] {
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("listener", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let err = expected;
|
||||
let bind: BindFn = Arc::new(move |_substrate, _backing| {
|
||||
let e = match err {
|
||||
"dial_failed" => TunnelEstablishError::DialFailed("held".to_string()),
|
||||
_ => TunnelEstablishError::ResourceShortage("exhausted".to_string()),
|
||||
};
|
||||
Box::pin(async move { Err(e) })
|
||||
});
|
||||
let establisher = bindfirst_establisher(registry, bind);
|
||||
let input = serde_json::json!({"resource": "listener", "substrate": "tcp"});
|
||||
let got = establisher(input, AuthContext::anonymous(b"alk/tunnel"))
|
||||
.await
|
||||
.expect_err("bind failure");
|
||||
let reason = match &got {
|
||||
EstablishmentError::DialFailed { .. } => "dial_failed",
|
||||
EstablishmentError::ResourceShortage { .. } => "resource_shortage",
|
||||
other => panic!("unexpected error shape {other:?}"),
|
||||
};
|
||||
assert_eq!(reason, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// The witness seam (`register_tunnel_bindfirst_openable`'s
|
||||
/// identity_witness): the wrapping establisher records the per-call
|
||||
/// opener identity, then delegates (the CF-006 probe contract).
|
||||
#[tokio::test]
|
||||
async fn bindfirst_witness_wrapper_records_then_delegates() {
|
||||
struct TestListener;
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("listener", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let bind: BindFn = Arc::new(|_substrate, _backing| {
|
||||
Box::pin(async {
|
||||
Ok(BoundListener {
|
||||
listener: Arc::new(TestListener) as Arc<dyn Any + Send + Sync>,
|
||||
bound: SubstrateAddr::Path {
|
||||
path: "/p".to_string(),
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
let witness: IdentityWitness = Arc::new(tokio::sync::Mutex::new(None));
|
||||
let inner = bindfirst_establisher(registry, bind);
|
||||
let wrapped = witness_establisher(inner, Arc::clone(&witness));
|
||||
let input = serde_json::json!({"resource": "listener", "substrate": "tcp"});
|
||||
let auth = AuthContext {
|
||||
identity: Some(alkcall::core::auth::Identity {
|
||||
id: "opener-9".to_string(),
|
||||
scopes: vec![],
|
||||
resources: Default::default(),
|
||||
}),
|
||||
..AuthContext::anonymous(b"alk/tunnel")
|
||||
};
|
||||
let establishment = wrapped(input, auth).await.expect("establishment");
|
||||
assert!(
|
||||
establishment.plan.is_some(),
|
||||
"delegation preserved the plan"
|
||||
);
|
||||
assert_eq!(
|
||||
*witness.lock().await,
|
||||
Some("opener-9".to_string()),
|
||||
"the per-call opener identity was recorded"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
id: tunnels/bindfirst-params
|
||||
name: Bind-first params — ListenParams, the forwarded-op spec, the bind-first plan type (ADR-008 as amended)
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [wire, params, bind-first, forwarded-op, correlation-token]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the bind-first wire surface's params half (ADR-008, as
|
||||
amended 2026-09-18 — Amendment 1): the listen-open reply fields
|
||||
(`bound` + `listen`), the forwarded-op params + spec, and the
|
||||
bind-first plan payload type. Split from the establisher/pump task
|
||||
(`tunnels/bindfirst-producer`) so the wire-stable types land and get
|
||||
pinned first — the same ordering `tunnels/params` used for the base
|
||||
op.
|
||||
|
||||
### params.rs — constants + types
|
||||
|
||||
```rust
|
||||
pub const OP_TUNNEL_FORWARDED: &str = "channels/tunnel/forwarded";
|
||||
pub const TUNNEL_FORWARDED_SCOPE: &str = "tunnel:forwarded";
|
||||
|
||||
/// The bind-first listen-open reply (ADR-008 §2 as amended): the two
|
||||
/// additive fields the establisher contributes. Extracted from the
|
||||
/// full success reply (`{channel_id, bound?, listen?}`).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TunnelListenReply {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bound: Option<SubstrateAddr>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub listen: Option<u32>,
|
||||
}
|
||||
|
||||
/// The forwarded-op input params (ADR-008 §3 as amended).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TunnelForwardedParams {
|
||||
pub listen: u32,
|
||||
pub substrate: Substrate,
|
||||
pub peer: SubstrateAddr,
|
||||
}
|
||||
|
||||
pub fn tunnel_forwarded_spec() -> OperationSpec
|
||||
```
|
||||
|
||||
- **The correlation token (Amendment 1):** `listen` (u32) — minted by
|
||||
the bind-first establisher, returned in the reply as the `listen`
|
||||
field, stamped into each forwarded open's `listen` param. NOT the
|
||||
listen channel's ID (the pre-amendment key is superseded — the
|
||||
serving-side handler never sees its own channel ID, and generic
|
||||
relays don't rewrite params; ADR-008 Amendment 1 has the full
|
||||
rationale). `SubstrateAddr` is defined by `tunnels/direct-op` —
|
||||
same value shape (`{host, port}` / `{path}`); if that task has not
|
||||
landed, define it here and let `tunnels/direct-op` consume it
|
||||
(either order works; the type lands once).
|
||||
- `tunnel_forwarded_spec()` per wire.md §The Forwarded Op:
|
||||
`Sub`-typed, `Visibility::External`, `ChannelOpenSpec::new(TUNNEL_ALPN)`,
|
||||
scope `["tunnel:forwarded"]` (ADR-006 Amendment 1 — ingress, a
|
||||
separate grant), input schema pinning all three fields required
|
||||
(`listen` integer > 0; `peer` an object with the per-substrate
|
||||
shape), output schema `{channel_id: integer > 0}`, description
|
||||
carried. **No establisher** on the forwarded op — the data plane
|
||||
starts immediately (ADR-008 §3).
|
||||
- `TunnelListenReply` is the consumer-side reader (the wire itself is
|
||||
plain JSON reply fields — additive, unknown fields ignored by old
|
||||
consumers; the struct is the typed view, not a deny-unknown wire
|
||||
gate on the reply — NOTE: the reply arrives as
|
||||
`{channel_id, bound?, listen?}`; parse it with a dedicated
|
||||
reply-viewing function that extracts the optional fields from the
|
||||
full output Value, not with `deny_unknown_fields` deserialization
|
||||
of the whole reply. The struct above is the projected pair; the
|
||||
parse helper handles `channel_id`'s presence).
|
||||
|
||||
### The bind-first plan type (producer.rs or a small shared module)
|
||||
|
||||
```rust
|
||||
/// The bind-first establisher's plan payload: the listener handle
|
||||
/// (typed-opaque to the pump via downcast) + the correlation token
|
||||
/// the accept dispatcher stamps into forwarded opens (ADR-008
|
||||
/// Amendment 1 — the handler cannot learn its own channel ID, so the
|
||||
/// establisher-minted token is the correlation key).
|
||||
pub struct BindFirstPlan {
|
||||
pub listener: TargetHandle, // the listener's halves? — NO: see the shape note
|
||||
pub token: u32,
|
||||
}
|
||||
```
|
||||
|
||||
- **Shape note (decide during implementation, document the choice):**
|
||||
the listener is NOT `TargetHandle` halves — a listener is accepted
|
||||
FROM, not pumped. The plan carries whatever the assembly's `BindFn`
|
||||
produced (opaque to the protocol), plus the token. Honest shape:
|
||||
`pub struct BindFirstPlan { pub listener: Arc<dyn Any + Send + Sync>, pub token: u32 }`
|
||||
— the accept dispatcher downcasts the `Any` to the assembly's
|
||||
listener type via a type-erased accept closure (the same injection
|
||||
pattern as `DialFn`/`AcceptFn`). The ADR's "listener handle" in the
|
||||
plan is assembly-typed; the protocol sees `BindFirstPlan { inner,
|
||||
token }`.
|
||||
- The token mint: `std::sync::atomic::AtomicU32` counter seeded
|
||||
non-zero (e.g. 1) inside the establisher closure state; wraps are
|
||||
acceptable (u32, per-producer uniqueness is all that's required —
|
||||
ADR-008 Amendment 1's token-space note; a wrap collision requires
|
||||
2^32 concurrent opens on one producer).
|
||||
- Wire-stability note for the implementer: `listen`'s u32 type and
|
||||
field name, the forwarded params field names, and the op id are
|
||||
one-way doors (ADR-008 Consequences). `deny_unknown_fields` on
|
||||
`TunnelForwardedParams` (loud rejection).
|
||||
|
||||
### Tests (unit, params.rs)
|
||||
|
||||
- `TunnelForwardedParams` round-trip per substrate; unknown-field and
|
||||
missing-field rejection; `listen: 0` rejected (schema minimum 1 +
|
||||
serde-level pin).
|
||||
- `TunnelListenReply` projected parse: full reply
|
||||
`{channel_id: 7, bound: {...}, listen: 918}` → both fields; bare
|
||||
`{channel_id: 7}` → both `None` (accept-first/dial shape); unknown
|
||||
extra reply fields tolerated (the additive posture — the reply
|
||||
viewer must not deny unknown fields).
|
||||
- Spec-shape conformance: op id, Sub type, scope, schema structure
|
||||
(all three required; peer object), ALPN marker, description.
|
||||
- No forwarded establisher: the registration test lands in
|
||||
`tunnels/bindfirst-producer` (this task pins the spec shape only).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `OP_TUNNEL_FORWARDED`/`TUNNEL_FORWARDED_SCOPE` constants;
|
||||
`tunnel_forwarded_spec()` matches wire.md §The Forwarded Op
|
||||
- [x] `TunnelForwardedParams` (deny-unknown, `listen: u32 > 0`) + the
|
||||
reply-projection viewer round-trip the pinned shapes
|
||||
- [x] `BindFirstPlan` (opaque listener + token) defined; the token
|
||||
mint documented (per-producer unique, per-open fresh)
|
||||
- [x] Unit tests green; wasm32 check passes (pure protocol types)
|
||||
- [x] Clippy/fmt clean
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
|
||||
(§2 as amended — the `listen` reply field; §3 as amended — the
|
||||
forwarded params; Amendment 1 — the token rationale)
|
||||
- docs/architecture/wire.md §The Listen Open Op's Reply +
|
||||
§The Forwarded Op
|
||||
- tasks/tunnels/params.md (the ordering precedent: wire types first)
|
||||
- tasks/tunnels/direct-op.md (`SubstrateAddr` — shared type)
|
||||
|
||||
## Notes
|
||||
|
||||
- `SubstrateAddr` had already landed (via `tunnels/direct-op`) —
|
||||
consumed, not redefined (the "either order works" branch resolved
|
||||
to the consuming side).
|
||||
- The reply viewer is `parse_listen_reply(&Value) ->
|
||||
Result<TunnelListenReply, TunnelError>`: parses the FULL success
|
||||
output (the `open_channel_with_reply` shape), tolerates unknown
|
||||
extra reply fields (the additive posture — no deny-unknown gate on
|
||||
the reply), requires a positive `channel_id` (its absence/zero =
|
||||
not an open reply — loud), and fails loud on malformed `bound`
|
||||
(not substrate-shaped) or `listen` (not a positive u32) via the
|
||||
new `TunnelError::MalformedReply` variant (error.rs). `null`
|
||||
fields parse as `None` (defensive; a well-formed reply omits them).
|
||||
- `TunnelForwardedParams.listen` carries a serde-level
|
||||
`positive_u32` pin behind the schema's `minimum: 1` (the task's
|
||||
"listen: 0 rejected (schema minimum 1 + serde-level pin)" — both
|
||||
gates, test-pinned).
|
||||
- The `peer` sub-schema follows the direct op's if/then precedent
|
||||
(tcp/udp → `{host, port}` with `additionalProperties: false` +
|
||||
`maximum: 65535`; unix → `{path}`), structural assertions per the
|
||||
no-jsonschema-dep precedent. The untagged-peer lenient-extra-fields
|
||||
asymmetry carries over from the direct op (schema = the shape gate;
|
||||
serde = the backstop) — documented in the test.
|
||||
- `BindFirstPlan` lives in `producer.rs` (the plan-payload family,
|
||||
next to `TargetHandle`): `{ listener: Arc<dyn Any + Send + Sync>,
|
||||
token: u32 }` per the honest shape — the listener is NOT
|
||||
`TargetHandle` halves (a listener is accepted FROM, not pumped);
|
||||
the accept dispatcher downcasts via a type-erased accept closure.
|
||||
The token-mint discipline (AtomicU32 seeded non-zero, per-open
|
||||
fresh, per-producer unique, wraps acceptable) is pinned in the
|
||||
type's doc — the establisher that implements it lands with
|
||||
`tunnels/bindfirst-producer`.
|
||||
- Custom `Debug` impls (matching `TargetHandle`'s precedent): the
|
||||
listener renders `<opaque>`, the token renders plainly.
|
||||
- Tests: 10 new unit tests in params.rs (forwarded round-trips per
|
||||
substrate, unknown/missing-field rejection, `listen: 0` serde
|
||||
rejection, full/bare/extra-field reply projection, malformed-reply
|
||||
rejection, spec conformance, peer-schema structure, the
|
||||
parse-backstop asymmetry) + the `BindFirstPlan`
|
||||
downcast-through-`ChannelPlan` test in producer.rs (incl. the
|
||||
wrong-type downcast failure).
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the bind-first wire surface's params half per ADR-008 as
|
||||
amended: `params.rs` — `OP_TUNNEL_FORWARDED` / `TUNNEL_FORWARDED_
|
||||
SCOPE` constants; `TunnelForwardedParams {listen, substrate, peer}`
|
||||
(deny-unknown, `listen` positive via schema `minimum: 1` + the serde
|
||||
pin); `tunnel_forwarded_spec()` (`Sub`-typed, External,
|
||||
`ChannelOpenSpec::new(TUNNEL_ALPN)`, scope `["tunnel:forwarded"]`,
|
||||
per-substrate if/then `peer` sub-schema, output
|
||||
`{channel_id: integer > 0}`, description, no establisher);
|
||||
`TunnelListenReply` + `parse_listen_reply` (the projected reply
|
||||
viewer — unknown extra fields tolerated, `channel_id` presence
|
||||
required, malformed fields fail loud via `TunnelError::
|
||||
MalformedReply`). `producer.rs` — `BindFirstPlan {listener:
|
||||
Arc<dyn Any + Send + Sync>, token: u32}` with the mint discipline
|
||||
pinned in docs (non-zero-seeded AtomicU32, per-open fresh,
|
||||
per-producer unique, wraps acceptable). Re-exported through
|
||||
`lib.rs`; the establisher/dispatcher/registration that consume these
|
||||
types land with `tunnels/bindfirst-producer`.
|
||||
|
||||
Verified: `cargo test` green (105 default; 125 with `--all-
|
||||
features`), clippy `--all-targets -D warnings` clean (native +
|
||||
wasm32), fmt clean, `wasm32-unknown-unknown` check passes (pure
|
||||
protocol types — the default crate stays wasm-clean), `cargo doc
|
||||
--no-deps` clean. CHANGELOG updated (Unreleased → Added).
|
||||
@@ -0,0 +1,332 @@
|
||||
---
|
||||
id: tunnels/bindfirst-producer
|
||||
name: Bind-first establisher + accept dispatcher + registration (ADR-008 shape 3)
|
||||
status: completed
|
||||
depends_on: [tunnels/bindfirst-params]
|
||||
scope: broad
|
||||
risk: high
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [producer, establisher, bind-first, accept-dispatcher, forwarded-op]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the bind-first establisher (producer.md §The Establisher,
|
||||
shape 3 — ADR-008 §1): the establisher binds (via the injected
|
||||
`BindFn`), mints the correlation token, contributes `bound` + `listen`
|
||||
to the reply, and returns the plan; the pump handler is the **accept
|
||||
dispatcher** — it accepts the channel's `BiStream`, runs the
|
||||
assembly-provided accept loop, and per accept opens
|
||||
`channels/tunnel/forwarded` back toward the listen opener and pumps
|
||||
the accepted halves against the adopted channel. Plus
|
||||
`register_tunnel_bindfirst_openable` wiring it.
|
||||
|
||||
### The establisher (shape 3 — bind-first)
|
||||
|
||||
```rust
|
||||
pub type BindFn = Arc<
|
||||
dyn Fn(&str) -> BoxFuture<'static, Result<BoundListener, TunnelEstablishError>>
|
||||
+ Send + Sync,
|
||||
>;
|
||||
|
||||
/// What the assembly's bind produces: the opaque listener (the accept
|
||||
/// loop's business) + the OS-chosen bound address (the `bound` reply
|
||||
/// field's source). The protocol never binds (OQ-TN-04).
|
||||
pub struct BoundListener {
|
||||
pub listener: Arc<dyn Any + Send + Sync>, // assembly-typed
|
||||
pub bound: SubstrateAddr,
|
||||
}
|
||||
|
||||
pub fn bindfirst_establisher(
|
||||
registry: ResourceRegistry, // namespace gate only, as listen_establisher
|
||||
bind: BindFn,
|
||||
) -> OpenEstablisher
|
||||
```
|
||||
|
||||
- Flow: parse `TunnelParams` → registry namespace gate
|
||||
(`unknown_resource` on miss — same posture as
|
||||
`listen_establisher`) → mint the correlation token
|
||||
(per-open-fresh, per-producer-unique; `tunnels/bindfirst-params`)
|
||||
→ `bind(substrate, backing)` → contribute reply fields
|
||||
(`Establishment::with_reply_field("bound", …).with_reply_field("listen", token)`)
|
||||
→ `Establishment::new(Arc::new(BindFirstPlan { listener, token }))`.
|
||||
- Error mapping (producer.md shape-3 table): unbindable/held port →
|
||||
`dial_failed` (the bind IS the dial here); bind resource
|
||||
exhaustion → `resource_shortage`; registry miss →
|
||||
`unknown_resource`; internal → `handler_error`. The `BindFn`
|
||||
returns the typed errors; the establisher passes them through.
|
||||
- The token lives in BOTH the reply and the plan (the dispatcher
|
||||
needs it; the opener needs it via the reply).
|
||||
|
||||
### The accept dispatcher (the shape-3 pump handler)
|
||||
|
||||
```rust
|
||||
pub fn make_bindfirst_accept_handler(
|
||||
accept_loop: BindFirstAcceptLoop,
|
||||
open_forwarded: ForwardedOpener,
|
||||
) -> OpenHandler
|
||||
```
|
||||
|
||||
- **Injection seams (the design pin — decide the exact closure
|
||||
signatures during implementation, document in Notes):** the
|
||||
dispatcher needs two assembly-provided capabilities the protocol
|
||||
cannot own:
|
||||
1. **The accept loop** — pops accepted connections from the
|
||||
`BindFirstPlan`'s opaque listener and yields
|
||||
`(SubstrateAddr /* peer */, TargetHandle /* accepted halves */)`.
|
||||
Substrate code lives behind the closure (ADR-004).
|
||||
2. **The forwarded-open caller** — opens `OP_TUNNEL_FORWARDED`
|
||||
with `TunnelForwardedParams { listen: token, substrate, peer }`
|
||||
on the channel-0 call plane toward the listen opener, adopts the
|
||||
opener-allocated ID (`adopt_channel` on the producer-side
|
||||
manager), and returns the streams. **This needs the call
|
||||
plane**: the per-channel `Connection` the handler receives is
|
||||
stream-less (`open_bi` is `StreamClosed` — ADR-035) and carries
|
||||
no call surface, so the registration must close over the
|
||||
serving session's `Arc<CallConnection>` (the reverse POC's
|
||||
`open_reverse_channel` shape: `call_with_payload` on channel 0 →
|
||||
extract `channel_id` from the reply → `manager.adopt_channel`).
|
||||
The registration function takes the call-plane handle (the
|
||||
assembly has it — it constructed the serving session); document
|
||||
the wiring.
|
||||
- Handler contract (producer.md §The Pump Handler item 5 + R-02):
|
||||
`conn.accept_bi()` FIRST (the yield-once contract — the wrapper's
|
||||
accepted-flag telemetry requires it), then run the accept loop
|
||||
concurrently with holding the channel's stream. The listen
|
||||
channel's own `BiStream` stays idle (no pumps on it); **its EOF
|
||||
cancels the loop and tears the listener down** — `select!` on the
|
||||
idle stream's EOF vs the accept loop (read from the stream and
|
||||
observe EOF; the zero-byte sentinel is the opener dropping the
|
||||
listener session). Per-accept data planes are `pump_bidi`
|
||||
(convention 8 — never a hand-rolled loop over one stream): each
|
||||
accepted handle pumps against its adopted forwarded channel inside
|
||||
a spawned task tracked by the handler's JoinHandle set.
|
||||
- **Lifetime (R-02):** the handler's returned JoinHandle tracks the
|
||||
whole accept loop + every per-accept pump it spawned (collect the
|
||||
pump handles; the handler task awaits them on loop exit — a
|
||||
forwarded pump outliving its listener is a leak). Handler exit →
|
||||
wrapper teardown (the listen channel reaps) → the far listener
|
||||
tears down (the `BindFn`'s listener drops with the plan/loop).
|
||||
- Error posture per accept: a forwarded-open failure (opener without
|
||||
the scope → FORBIDDEN; old opener → NOT_FOUND; correlation miss →
|
||||
handler_error-class) logs and drops that accepted connection — the
|
||||
loop continues; the listener's overall health is not hostage to one
|
||||
failed accept. (Decide: retry/backoff — v1 says no; document.)
|
||||
|
||||
### Registration
|
||||
|
||||
```rust
|
||||
pub fn register_tunnel_bindfirst_openable(
|
||||
core: &ChannelCore,
|
||||
registry: &ResourceRegistry,
|
||||
on_registry: &Arc<OperationRegistry>,
|
||||
auth: AuthContext,
|
||||
call_plane: Arc<CallConnection>, // the producer-side call plane for forwarded opens
|
||||
manager: &ChannelManager, // the producer-side manager (adopt the forwarded ids)
|
||||
bind: BindFn,
|
||||
accept_loop: BindFirstAcceptLoop,
|
||||
identity_witness: Option<IdentityWitness>,
|
||||
) -> Result<(), String>
|
||||
```
|
||||
|
||||
- Same spec (`tunnel_open_spec()`) + scope gate (`tunnel:open`) as the
|
||||
dial/listen variants — the bind-first shape is a per-registration
|
||||
establisher choice over the same op (ADR-008 §5's per-registration
|
||||
posture). One establisher per op id per registry — same note as
|
||||
`register_tunnel_listen_openable`.
|
||||
- The forwarded-op SERVING side (opener side) is a separate
|
||||
registration shape (`register_openable` — no establisher) and lands
|
||||
with `tunnels/forwarded-receipt`; this task's tests still need an
|
||||
opener-side stub (see Tests).
|
||||
|
||||
### Tests (integration — the duplex harness, listen-topology extension)
|
||||
|
||||
- **The full bind-first flow e2e:** opener opens
|
||||
`channels/tunnel/sub` with the bind-first producer registered →
|
||||
reply carries `bound` (the real OS/duplex-chosen address) +
|
||||
`listen` (the token) → the opener serves `channels/tunnel/forwarded`
|
||||
(scope `tunnel:forwarded`) with a correlation map keyed on the
|
||||
token → the test accept loop accepts a duplex connection → the
|
||||
producer's dispatcher opens forwarded with
|
||||
`{listen: token, substrate, peer}` → the opener correlates, adopts,
|
||||
pumps against the "local BIND client" halves → byte round-trip
|
||||
through the forwarded channel. This test IS the ADR-008 acceptance
|
||||
gate.
|
||||
- Reply-field projection e2e: `open_channel_with_reply` sees both
|
||||
fields; a dial establisher's open sees neither (the reserved-key
|
||||
guard upstream guarantees `channel_id` stays wrapper-owned).
|
||||
- EOF teardown: drop the listener session (close the listen channel's
|
||||
stream) → the accept loop cancels, the far listener is gone (a
|
||||
follow-on accept attempt fails), the forwarded channels already
|
||||
adopted own their teardown independently.
|
||||
- Per-accept failure isolation: opener rejects one forwarded open
|
||||
(e.g. scope-less identity on a second opener registry) → that
|
||||
accept's connection drops, the loop serves the next accept.
|
||||
- Establishment error paths: bind failure → `dial_failed`-class on
|
||||
the wire (`channel:open_failed`); registry miss →
|
||||
`unknown_resource`.
|
||||
- Token discipline: two opens on the same producer mint distinct
|
||||
tokens; each dispatcher stamps ITS OWN plan's token.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `bindfirst_establisher`: registry gate → mint token → bind →
|
||||
`bound`+`listen` reply fields → `BindFirstPlan` plan-flow
|
||||
- [x] The accept dispatcher: `accept_bi` first (R-02 telemetry),
|
||||
idle-stream EOF cancels the loop + tears the listener down,
|
||||
per-accept `pump_bidi` tasks tracked by the handler JoinHandle
|
||||
- [x] The forwarded-open caller rides the call plane
|
||||
(`call_with_payload` + adopt — the reverse POC's shape); the
|
||||
registration wiring documents where the call plane comes from
|
||||
- [x] `register_tunnel_bindfirst_openable` wires spec + establisher +
|
||||
dispatcher + injections
|
||||
- [x] Integration tests: the full flow e2e (the acceptance gate),
|
||||
reply-field projection, EOF teardown, per-accept isolation,
|
||||
typed establishment errors, token discipline
|
||||
- [x] Clippy/fmt clean; wasm32 check passes (closures injected; no
|
||||
substrate code in the default crate)
|
||||
- [x] `cargo test` green
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
|
||||
(§1 shape 3, §2 the reply fields, §3 the forwarded op, Amendment 1
|
||||
— the token; §6 the hub policy sentence)
|
||||
- docs/architecture/producer.md §The Establisher (shape 3) + §The
|
||||
Pump Handler (item 5) + §The Forwarded Op
|
||||
- tasks/tunnels/bindfirst-params.md (the types this consumes)
|
||||
- tasks/tunnels/producer-open-op.md + producer-listen.md (the
|
||||
harness + registration shapes this extends)
|
||||
- alkcall surface: `Establishment::with_reply_field` (ADR-049
|
||||
amendment 3, landed 0.8.0), the `OpenHandler` signature (no channel
|
||||
ID — Amendment 1's premise), `ChannelRelay` reply-field pass-through
|
||||
(ADR-051 — the relay-transparency property the tests should pin)
|
||||
|
||||
## Notes
|
||||
|
||||
- **Closure signatures decided (the design pin):** `BindFn` /
|
||||
`BindFirstAcceptLoop` / `ForwardedOpener` follow the type-erased
|
||||
closure precedent:
|
||||
- `BindFn = Arc<dyn Fn(Substrate, &str) -> BoxFuture<'static,
|
||||
Result<BoundListener, TunnelEstablishError>> + Send + Sync>` —
|
||||
same `(substrate, backing)` form as `DialFn` (the registry
|
||||
backing string rides the same signature; the bind closure stays
|
||||
substrate-agnostic from the protocol's view).
|
||||
- `BindFirstAcceptLoop = Arc<dyn Fn(Arc<dyn Any + Send + Sync>) ->
|
||||
BoxFuture<'static, Result<(SubstrateAddr, TargetHandle),
|
||||
TunnelEstablishError>> + Send + Sync>` — the closure receives the
|
||||
plan's OPAQUE listener (`Arc<dyn Any + Send + Sync>`, no
|
||||
downcast in the protocol: the assembly downcasts to ITS concrete
|
||||
type inside the closure; the same injection pattern as
|
||||
`DialFn`/`AcceptFn`). A returned `Err` is LOOP-CRITICAL (the
|
||||
substrate accept source broke) — the dispatcher ends the loop;
|
||||
per-accept forwarded-open failures never come from this closure.
|
||||
- `ForwardedOpener = Arc<dyn Fn(TunnelForwardedParams) ->
|
||||
BoxFuture<'static, Result<(MpscSendStream, MpscRecvStream),
|
||||
TunnelEstablishError>> + Send + Sync>` — closes over the serving
|
||||
session's `Arc<CallConnection>` + the producer-side
|
||||
`ChannelManager` (the reverse POC's `open_reverse_channel`
|
||||
shape: `call_with_payload` on channel 0 → extract `channel_id` →
|
||||
`manager.adopt_channel`). **Where the call plane comes from:** the
|
||||
assembly layer holds it — it constructed the serving session
|
||||
(the harness's `wire_bindfirst` shows the wiring: the
|
||||
`ForwardedOpener` closure closes over the `Arc<ChannelClient>`
|
||||
and calls `call_open_op` + `adopt_channel`; in production the
|
||||
assembly hands its `CallConnection` the same way).
|
||||
- **Registration signature deviation (documented):** the task sketch
|
||||
had 8 parameters (clippy's `too_many_arguments`); the injected
|
||||
seams bundle into `BindFirstInjections {bind, accept_loop,
|
||||
open_forwarded}` — one coherent injection point passed beside the
|
||||
core pair + the optional witness. Same posture as `DialFn` riding
|
||||
the dial registration. The `identity_witness` seam is wired via a
|
||||
small `witness_establisher` wrapper (records the per-call opener
|
||||
identity, then delegates — the same CF-006 seam the dial/direct
|
||||
variants inline).
|
||||
- **The token mint** (`mint_token`, private): `AtomicU32` counter
|
||||
seeded at 1 inside the establisher's closure state; `fetch_add`
|
||||
returns the PREVIOUS value, so the minted stream is 1, 2, 3, … —
|
||||
token 0 (an illegal wire value) is never minted, and a u32 wrap
|
||||
that lands on 0 re-increments (the loop skips it). Per-open fresh,
|
||||
per-producer unique; unit-pinned including the wrap shape.
|
||||
- **The idle-stream EOF watch:** a 1-byte `read` on the split idle
|
||||
half inside a `select!` against the accept loop — `Ok(0)` (the
|
||||
zero-byte EOF sentinel), a read error, or (defensively) any
|
||||
non-empty read on an idle stream all cancel the loop. The listen
|
||||
channel's stream is never pumped (no `pump_bidi` on it).
|
||||
- **Per-accept failure posture (v1, documented):** a forwarded-open
|
||||
failure (scope-less opener → FORBIDDEN; old opener → NOT_FOUND;
|
||||
correlation miss → handler_error-class) logs a warning and drops
|
||||
that accepted connection — the loop continues; NO retry/backoff
|
||||
(v1 posture per the task).
|
||||
- **Lifetime (R-02):** the handler collects every per-accept
|
||||
`pump_bidi` `JoinHandle` in a `Vec` and awaits them all on loop
|
||||
exit (before the handler task returns) — a forwarded pump never
|
||||
outlives its listener. Handler exit → wrapper teardown → the listen
|
||||
channel reaps → the plan's listener drops with the loop.
|
||||
- **Harness changes (test infrastructure):** `wire_bindfirst`
|
||||
(RegistrationMode::BindFirst); the consumer's install hook gained a
|
||||
shared serving registry + the accept-side identity propagation
|
||||
(the transport identity attaches to channel 0 — the same posture
|
||||
`wire_forward` models; needed because the forwarded opens arrive
|
||||
ON the consumer's serving path); `Topology.consumer_registry`
|
||||
exposed; the forwarded-op serving stub
|
||||
(`register_forwarded_stub_on_consumer` /
|
||||
`make_forwarded_stub_handler` — correlate by `listen` token →
|
||||
accept the `BiStream` → `pump_bidi` against the correlated halves;
|
||||
the opener-side registration shape lands for real with
|
||||
`tunnels/forwarded-receipt`).
|
||||
- **Test-data-plane shape:** the e2e round-trip drives the local
|
||||
BIND client end directly (the correlation map's halves are one end
|
||||
of a raw duplex; the stub's pump bridges forwarded channel ↔ those
|
||||
halves; the accepted side's echo task closes the loop). The
|
||||
forwarded channel on the opener side is wrapper-managed (the
|
||||
serving wrapper allocated it — `adopt_channel` on it a second time
|
||||
fails `ChannelExists`), so the test drives the client end, not a
|
||||
re-adopt.
|
||||
- **EOF-teardown observability:** the test pins the listen channel
|
||||
reaping on the producer side after the opener drops its session
|
||||
(`has_channel` false) and the accept loop's death via the queue
|
||||
(a follow-on push is never consumed).
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the bind-first producer (shape 3, ADR-008 as amended) per
|
||||
the task: `src/producer.rs` — `BindFn` + `BoundListener` (the
|
||||
platform-boundary bind seam); the token mint (non-zero-seeded
|
||||
`AtomicU32`, per-open fresh, per-producer unique, token 0 never
|
||||
minted — the wrap shape unit-pinned); `bindfirst_establisher`
|
||||
(registry namespace gate → mint → bind → `bound` + `listen` reply
|
||||
fields → the `BindFirstPlan` plan; the typed errors pass through —
|
||||
bind failure `dial_failed`, exhaustion `resource_shortage`, registry
|
||||
miss `unknown_resource`); `BindFirstAcceptLoop` (the type-erased
|
||||
accept closure receiving the opaque listener); `ForwardedOpener` (the
|
||||
call-plane forwarded-open caller: call + adopt);
|
||||
`make_bindfirst_accept_handler` (the accept dispatcher: `accept_bi`
|
||||
first, the idle-stream EOF sentinel cancels the loop via `select!`,
|
||||
per-accept `pump_bidi` in spawned tasks tracked by the handler's
|
||||
`JoinHandle`, per-accept failure isolation, loop-critical accept
|
||||
errors end the loop); `register_tunnel_bindfirst_openable` (same
|
||||
spec + scope gate, `BindFirstInjections` bundle + witness seam) —
|
||||
re-exported through `lib.rs`. `tests/harness.rs` —
|
||||
`RegistrationMode::BindFirst` + `wire_bindfirst` (the consumer hook
|
||||
gained the accept-side identity propagation + a shared serving
|
||||
registry, `Topology.consumer_registry` exposed) + the forwarded-op
|
||||
serving stub (`make_forwarded_stub_handler`). `tests/
|
||||
producer_bindfirst.rs` — 6 integration tests: the full flow e2e (the
|
||||
acceptance gate: reply projection → token correlation → forwarded
|
||||
open → byte round-trip through the whole path), the dial-open
|
||||
neither-field projection, EOF teardown (listen reaped, loop gone),
|
||||
per-accept isolation (scope-less opener; the loop serves the next
|
||||
accept), typed establishment errors, and the token discipline (two
|
||||
opens distinct tokens; each dispatcher stamps its own plan's token).
|
||||
Plus 6 unit tests in producer.rs (the mint discipline incl. the wrap
|
||||
shape, the establisher flow at the `Establishment` surface — plan
|
||||
token == reply token — distinct tokens per open, registry miss,
|
||||
bind-error passthrough, the witness wrapper).
|
||||
|
||||
Verified: `cargo test` green (118 default; 138 `--all-features`),
|
||||
clippy `--all-targets -D warnings` clean (native + wasm32), fmt
|
||||
clean, `wasm32-unknown-unknown` check passes (closures injected; the
|
||||
default crate stays wasm-clean), `cargo doc --no-deps` clean.
|
||||
CHANGELOG updated (Unreleased → Added).
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: tunnels/coverage-weak-spots
|
||||
name: "Coverage weak spots — CLOSED (2026-09-09 second pass): the UdpHalf sender-block recipe found, feed infallible, wrappers covered"
|
||||
status: done
|
||||
status: completed
|
||||
depends_on: [tunnels/review-impl]
|
||||
scope: single
|
||||
risk: low
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
---
|
||||
id: tunnels/direct-op
|
||||
name: Direct op — params, spec, establisher, consumer open_direct (ADR-007)
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: broad
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [wire, params, direct-op, producer, consumer, egress]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the direct op (`channels/tunnel/direct`, ADR-007) end to
|
||||
end: the params type + spec builder (`src/params.rs`), the establisher
|
||||
+ registration (`src/producer.rs`), and the consumer constructor
|
||||
(`src/consumer.rs`). The op rides the same `alk/tunnel` ALPN (ADR-002
|
||||
Amendment 1's flavor-form op ids — the upstream discovery derivation
|
||||
landed in alkcall 0.8.0, ADR-047 amendment 3). This is the ssh
|
||||
`direct-tcpip` / SOCKS5 CONNECT egress capability with structural ACL
|
||||
separation from `tunnel:open`.
|
||||
|
||||
### params.rs — types + spec
|
||||
|
||||
```rust
|
||||
pub const OP_TUNNEL_DIRECT: &str = "channels/tunnel/direct";
|
||||
pub const TUNNEL_DIRECT_SCOPE: &str = "tunnel:direct";
|
||||
|
||||
/// The substrate-shaped address object (ADR-007 §2): keyed by the
|
||||
/// sibling `substrate` field of the params. Also the `peer` value
|
||||
/// shape of the forwarded op (ADR-008 §3) — define once here.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum SubstrateAddr {
|
||||
HostPort { host: String, port: u16 },
|
||||
Path { path: String },
|
||||
}
|
||||
|
||||
/// The direct-op input params (ADR-007 §2). Wire-stable.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TunnelDirectParams {
|
||||
pub substrate: Substrate,
|
||||
pub target: SubstrateAddr,
|
||||
}
|
||||
|
||||
pub fn tunnel_direct_spec() -> OperationSpec
|
||||
```
|
||||
|
||||
- Spec per wire.md §The Direct Op: `Sub`-typed, `Visibility::External`,
|
||||
`ChannelOpenSpec::new(TUNNEL_ALPN)`, scope `["tunnel:direct"]`
|
||||
(**never implied by `tunnel:open`** — ADR-006 Amendment 1), output
|
||||
schema `{channel_id: integer > 0}`.
|
||||
- `input_schema` pins both fields required and the target sub-schema
|
||||
per-substrate — a `oneOf` on the sibling `substrate` value
|
||||
(`tcp`/`udp` → `{host, port}`, `unix` → `{path}`), so malformed
|
||||
targets fail the registry's schema validation (the typed
|
||||
`INVALID_INPUT` class) **before** the establisher runs. The
|
||||
establisher's serde parse is the second gate (defensive; see the
|
||||
error-mapping note).
|
||||
- `deny_unknown_fields` on both params types (the loud-rejection
|
||||
posture, ADR-001's precedent).
|
||||
- The `Substrate` enum is reused as the discriminator (no new
|
||||
substrate values — ADR-001's additive rule).
|
||||
|
||||
### producer.rs — the establisher + registration
|
||||
|
||||
```rust
|
||||
pub fn direct_establisher(dial: DialFn) -> OpenEstablisher
|
||||
|
||||
pub fn register_tunnel_direct_openable(
|
||||
core: &ChannelCore,
|
||||
on_registry: &Arc<OperationRegistry>,
|
||||
auth: AuthContext,
|
||||
dial: DialFn, // the SAME DialFn the base op uses
|
||||
identity_witness: Option<IdentityWitness>,
|
||||
) -> Result<(), String>
|
||||
```
|
||||
|
||||
- **DialFn reuse is the design pin**: the establisher parses
|
||||
`TunnelDirectParams`, renders the target to the same backing-string
|
||||
form the registry backings use (`"host:port"` for tcp/udp, the path
|
||||
for unix), and calls the injected `DialFn(substrate, rendered)`.
|
||||
No second dial type — a producer that registered `dial_tcp` for the
|
||||
base op registers the direct op with zero new substrate code
|
||||
(ADR-007's `local` interaction note). The UDP framed-adapter wrap
|
||||
(ADR-003) happens inside the dial closure (the `local` feature's
|
||||
`connect_udp` already does this) — the establisher stays
|
||||
substrate-agnostic.
|
||||
- **No registry lookup** — `unknown_resource` can never fire
|
||||
(ADR-007 §2). Error mapping (ADR-007 §4's table minus the registry
|
||||
row): the serde-parse failure of a schema-valid-shaped target maps
|
||||
`HandlerError` (the schema gate should have caught it — the parse
|
||||
is the backstop); dial failures pass through the `DialFn`'s own
|
||||
typed errors unchanged (`dial_failed` / `resource_shortage` /
|
||||
`handler_error`).
|
||||
- The per-call `auth` carries the opener identity (CF-006) — the
|
||||
witness seam records it, same as the base op (the direct op is the
|
||||
arbitrary-egress capability; identity visibility here matters more,
|
||||
not less).
|
||||
- Registration uses the SAME pump handler (`make_tunnel_pump_handler`
|
||||
— the direct op's plan payload is a `TargetHandle` exactly like the
|
||||
base op's; plan-flow is unchanged).
|
||||
|
||||
### consumer.rs — `open_direct`
|
||||
|
||||
```rust
|
||||
pub async fn open_direct(
|
||||
client: &ChannelClient,
|
||||
target: SubstrateAddr,
|
||||
substrate: Substrate,
|
||||
) -> Result<Self, TunnelOpenError>
|
||||
```
|
||||
|
||||
- A separate constructor, not a params enum (ADR-007 §5: two scopes
|
||||
are two capabilities; the caller states which one it holds).
|
||||
Serializes `{substrate, target}`, `open_channel(OP_TUNNEL_DIRECT, …,
|
||||
TUNNEL_ALPN)`, `from_halves` — identical session type, data planes,
|
||||
and teardown API as `open`.
|
||||
- Uses plain `open_channel` (no reply fields expected on the direct
|
||||
op — dial establishers never bind; wire.md §The Direct Op).
|
||||
- Documented error surface: the base open errors minus
|
||||
`unknown_resource`; malformed targets are the schema/
|
||||
`invalid_input`-class failure (registry-side); an old producer
|
||||
rejects the op loudly (`NOT_FOUND` — the SSH posture).
|
||||
|
||||
### Tests
|
||||
|
||||
Unit (params.rs): `TunnelDirectParams` round-trip per substrate;
|
||||
untagged `SubstrateAddr` disambiguation (`{host, port}` vs `{path}`);
|
||||
`deny_unknown_fields` rejection; spec-shape conformance to wire.md §The
|
||||
Direct Op (op id, scope, oneOf schema structure — structural
|
||||
assertions, no jsonschema dep here, per `tunnels/params`'s precedent).
|
||||
|
||||
Integration (extend the duplex harness from `tunnels/producer-open-op`):
|
||||
- `open_direct` e2e round-trip through `pump_bidi` (tcp duplex target).
|
||||
- `open_direct` udp e2e (the framed adapter wraps at the dial closure —
|
||||
datagram boundary preserved).
|
||||
- Malformed target (unix without `path`) → schema-class failure, no
|
||||
phantom session.
|
||||
- Producer without the direct op registered → `NOT_FOUND`-class.
|
||||
- Identity without `tunnel:direct` (but with `tunnel:open`) →
|
||||
FORBIDDEN (the scopes do not imply each other).
|
||||
- Direct establisher sees the per-call opener identity (witness).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `TunnelDirectParams`/`SubstrateAddr` round-trip; malformed
|
||||
targets rejected (serde + schema shape pinned)
|
||||
- [x] `tunnel_direct_spec()` matches wire.md §The Direct Op exactly
|
||||
- [x] `direct_establisher`: no registry lookup, target rendering into
|
||||
the `DialFn` backing string, typed errors per ADR-007 §4
|
||||
- [x] `register_tunnel_direct_openable` reuses the base pump handler +
|
||||
dial injection; witness seam present
|
||||
- [x] `TunnelSession::open_direct` (separate ctor; identical
|
||||
session/teardown semantics)
|
||||
- [x] Integration tests: e2e round-trips (tcp + udp), malformed-target
|
||||
rejection, scope separation (FORBIDDEN), NOT_FOUND posture,
|
||||
per-call identity witness
|
||||
- [x] Clippy/fmt clean; wasm32 check passes (no substrate code added)
|
||||
- [x] `cargo test` green
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/007-direct-open-op.md (the normative op)
|
||||
- docs/architecture/producer.md §The Direct Op; consumer.md §Direct
|
||||
path; wire.md §The Direct Op
|
||||
- docs/architecture/decisions/006-access-control-posture.md Amendment 1
|
||||
- tasks/tunnels/params.md + tasks/tunnels/producer-open-op.md (the
|
||||
shapes this extends; the harness to extend)
|
||||
|
||||
## Notes
|
||||
|
||||
- The `input_schema` pins the per-substrate target sub-schema as two
|
||||
`if`/`then` branches under `allOf`, keyed on the sibling
|
||||
`substrate` value (tcp/udp → `{host, port}` with
|
||||
`additionalProperties: false`; unix → `{path}`, same posture) —
|
||||
JSON Schema `if/then` composes through alkcall's jsonschema 0.46
|
||||
compile, and the integration tests prove the gate fires
|
||||
(`INVALID_INPUT` before the establisher). Structural assertions
|
||||
pin the branch shape per `tunnels/params`'s no-jsonschema-dep
|
||||
precedent.
|
||||
- Untagged `SubstrateAddr` is deliberately lenient about extra target
|
||||
fields (untagged variants carry no `deny_unknown_fields`): a
|
||||
cross-shape target parses as whichever variant fits. The schema is
|
||||
the shape-vs-substrate gate; the establisher's serde parse is the
|
||||
backstop only (unknown substrate value → `handler_error`,
|
||||
`direct_establisher` maps `TunnelEstablishError::HandlerError`).
|
||||
This asymmetry is documented in both the type docs and the test.
|
||||
- `TunnelDirectParams::render_target()` centralizes the backing-string
|
||||
form (`"host:port"` / path) — the establisher injects the SAME
|
||||
`DialFn` with the rendered string; the render is pinned by test to
|
||||
the registry-backing form.
|
||||
- `TunnelSession::open_direct` uses plain `open_channel` (no reply
|
||||
fields — dial establishers never bind); `SubstrateAddr` re-exports
|
||||
as the future forwarded-op `peer` shape (ADR-008 §3, defined once).
|
||||
- Harness: `RegistrationMode::Direct`, `wire_direct` /
|
||||
`wire_direct_with_identity` (the raw-call topology),
|
||||
`wire_direct_forward` / `wire_direct_forward_with_identity` (the
|
||||
session-level forward topology; identity parameterized so the
|
||||
session-level FORBIDDEN probe runs through the real client API),
|
||||
`direct_identity` / `both_scopes_identity`.
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the direct op end to end per ADR-007:
|
||||
|
||||
- **params.rs** — `OP_TUNNEL_DIRECT` / `TUNNEL_DIRECT_SCOPE`
|
||||
constants; `SubstrateAddr` (untagged `{host, port}` | `{path}`,
|
||||
the ADR-008 §3 `peer` shape too); `TunnelDirectParams`
|
||||
(`deny_unknown_fields`, `render_target()`); `tunnel_direct_spec()`
|
||||
(`Sub`-typed, External, `ChannelOpenSpec::new(TUNNEL_ALPN)`, scope
|
||||
`["tunnel:direct"]`, output `{channel_id: integer > 0}`,
|
||||
per-substrate `if/then` target sub-schema). 7 unit tests: round-
|
||||
trips per substrate, untagged disambiguation, loud unknown-field
|
||||
rejection, spec-shape conformance, schema structure.
|
||||
- **producer.rs** — `direct_establisher` /
|
||||
`direct_establisher_with_witness` (parse → render → injected
|
||||
`DialFn`; no registry lookup; typed-error mapping per ADR-007 §4
|
||||
minus the registry row; CF-006 witness seam) +
|
||||
`register_tunnel_direct_openable` (direct spec + establisher + the
|
||||
SAME `make_tunnel_pump_handler`, plan-flow unchanged).
|
||||
- **consumer.rs** — `TunnelSession::open_direct(client, target,
|
||||
substrate)`: separate constructor, serializes `{substrate, target}`,
|
||||
plain `open_channel(OP_TUNNEL_DIRECT, …, TUNNEL_ALPN)`,
|
||||
`from_halves` — identical session/data-planes/teardown as `open`.
|
||||
- **lib.rs** — the new surface re-exported.
|
||||
|
||||
Tests: `tests/producer_direct_op.rs` — 15 integration tests over the
|
||||
duplex harness: tcp + udp e2e round-trips through `pump_bidi`
|
||||
(datagram boundary preserved via the framed dial closure), malformed
|
||||
targets (`unix` without `path`, `tcp` without `port`, missing target)
|
||||
→ `INVALID_INPUT` with no phantom channel/session, dial-failure
|
||||
pass-through (`dial_failed`), NOT_FOUND on a producer without the op,
|
||||
scope separation both ways (`tunnel:open` ↛ `tunnel:direct` and
|
||||
`tunnel:direct` ↛ `tunnel:open` — FORBIDDEN, raw-call AND session-
|
||||
level), CF-006 witness on the direct establisher (raw + session
|
||||
paths), the establisher parse backstop (`handler_error`), and the
|
||||
target-rendering pin (the dial closure observes the exact
|
||||
backing-string form). Harness: `Direct` registration mode +
|
||||
`wire_direct*` topologies + `direct_identity`/`both_scopes_identity`.
|
||||
|
||||
Verified: `cargo test` green (93 native; 94 with `--all-features`),
|
||||
clippy `-D warnings` clean (native + wasm32), fmt clean, wasm32
|
||||
check passes (default crate stays wasm-clean — no substrate code
|
||||
added), `cargo doc --no-deps` clean. CHANGELOG updated (Unreleased →
|
||||
Added).
|
||||
@@ -0,0 +1,242 @@
|
||||
---
|
||||
id: tunnels/forwarded-receipt
|
||||
name: Forwarded-op serving side + TunnelSession adopt path (the opener half)
|
||||
status: completed
|
||||
depends_on: [tunnels/bindfirst-params]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [opener, forwarded-op, serving, adopt, correlation]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the forwarded op's SERVING side (the listen-opener half,
|
||||
ADR-008 §3): `register_tunnel_forwarded_openable` — the
|
||||
`register_openable` shape (no establisher; the data plane starts
|
||||
immediately), scope-gated `tunnel:forwarded`, whose handler parses
|
||||
`TunnelForwardedParams`, hands the correlation material to the
|
||||
assembly, accepts the `BiStream`, and presents it for pumping against
|
||||
the local session's halves. Plus the typed consumer surface for
|
||||
reading the listen reply (`bound`/`listen` extraction, shared with
|
||||
`tunnels/tunnel-listener`).
|
||||
|
||||
### The serving registration (opener side)
|
||||
|
||||
```rust
|
||||
/// The correlation material the opener-side handler yields per
|
||||
/// forwarded open: the parsed params + the channel's BiStream
|
||||
/// accessor. The assembly correlates (token → its waiting listener
|
||||
/// session bookkeeping) and drives the pump against the local
|
||||
/// halves. The protocol provides the op + params, not the wait
|
||||
/// state machine (producer.md §The Forwarded Op).
|
||||
pub type ForwardedHandler = Arc<
|
||||
dyn Fn(TunnelForwardedParams, BiStream) -> tokio::task::JoinHandle<()>
|
||||
+ Send + Sync,
|
||||
>;
|
||||
|
||||
pub fn make_forwarded_op_handler(handler: ForwardedHandler) -> OpenHandler
|
||||
|
||||
pub fn register_tunnel_forwarded_openable(
|
||||
core: &ChannelCore,
|
||||
on_registry: &Arc<OperationRegistry>,
|
||||
auth: AuthContext,
|
||||
handler: ForwardedHandler,
|
||||
) -> Result<(), String>
|
||||
```
|
||||
|
||||
- **No establisher** (`register_openable` — the forwarded channel
|
||||
needs no establishment; its data plane starts immediately — ADR-008
|
||||
§3). The wrapper's `channel_id` allocation IS the reply (the
|
||||
producer adopts it).
|
||||
- The built-in handler shape: parse
|
||||
`TunnelForwardedParams` (`deny_unknown_fields`; a malformed open →
|
||||
the handler logs + returns early — teardown-at-birth is correct for
|
||||
a malformed ingress), accept the `BiStream`, invoke the injected
|
||||
assembly handler with the parsed params + the stream. The assembly
|
||||
correlates (via `listen`), adopts nothing (the WRAPPER owns this
|
||||
channel — the serving side allocated it; the assembly pumps the
|
||||
BiStream against its local halves via `pump_bidi`, and the wrapper's
|
||||
R-02 tracking reaps on handler exit).
|
||||
- **Pump responsibility split (the honest-shape decision to make and
|
||||
document):** the injected `ForwardedHandler` receives the channel
|
||||
`BiStream` and the params — the ASSEMBLY owns the pump
|
||||
(`pump_bidi(forwarded_bidi, local_read, local_write)`), because the
|
||||
local halves are assembly state (the SOCKS5 BIND client's socket).
|
||||
The protocol-side built-in handler only bridges wrapper→assembly.
|
||||
Await the injected handler's JoinHandle inline (R-02 — the
|
||||
forwarded channel's lifetime tracks it).
|
||||
- Identity: the opener-side gate sees the PRODUCER's per-call
|
||||
identity (CF-005 precedence — the producer may carry `auth_token`
|
||||
when it is itself forwarding); the `peer` param is
|
||||
producer-asserted metadata, not transport truth (producer.md's
|
||||
posture — ACL trusts the grant, app treats `peer` as metadata).
|
||||
The registration's `auth` + the per-call overlay handle this
|
||||
automatically (upstream machinery); nothing to implement, but the
|
||||
test suite pins it.
|
||||
- The `listen`-token correlation failure (no matching session) is the
|
||||
ASSEMBLY's rejection (it holds the map): the injected handler's
|
||||
error path — drop the stream, return (teardown-at-birth reaps the
|
||||
channel). Document that a correlation miss tears the forwarded
|
||||
channel down; the producer's accept loop drops the accepted
|
||||
connection on the open's failure (the shapes mirror).
|
||||
|
||||
### The typed reply reader (shared with TunnelListener)
|
||||
|
||||
```rust
|
||||
/// Extract the bind-first reply fields from an open-op success reply
|
||||
/// (the full output Value — `channel_id` + optional `bound`/`listen`).
|
||||
pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
|
||||
```
|
||||
|
||||
- Tolerates unknown fields (the additive posture); both fields
|
||||
`Option` (absent for dial/accept-first producers).
|
||||
- Lands here (not in `tunnels/tunnel-listener`) so the projection
|
||||
logic is unit-testable independent of the session type.
|
||||
|
||||
### Tests
|
||||
|
||||
- Serving-side e2e with the harness's producer-leg stub: an open with
|
||||
valid params + scope → handler invoked with the parsed params +
|
||||
the stream; the assembly's test handler pumps against local
|
||||
duplex halves → byte round-trip. (The full bind-first flow e2e
|
||||
lives in `tunnels/bindfirst-producer`; this task's e2e is the
|
||||
serving half standalone.)
|
||||
- Scope gate: identity without `tunnel:forwarded` → FORBIDDEN (never
|
||||
implied by `tunnel:open`/`tunnel:direct`).
|
||||
- Malformed params (missing `peer`, `listen: 0`, unknown field) →
|
||||
handler logs + early return → the channel tears down (no phantom).
|
||||
- The reply reader: full/empty/extra-field replies parse per the
|
||||
additive posture (unit).
|
||||
- Old-opener posture is upstream's registry lookup (`NOT_FOUND`) —
|
||||
pin it in the producer-side test in `tunnels/bindfirst-producer`
|
||||
(cross-referenced, not duplicated here).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `register_tunnel_forwarded_openable`: no-establisher
|
||||
registration, scope gate `tunnel:forwarded`, injected
|
||||
assembly handler receives parsed params + BiStream
|
||||
- [ ] The injected handler's JoinHandle is awaited inline (R-02);
|
||||
correlation-miss teardown semantics documented + tested
|
||||
- [ ] `listen_reply_fields` extraction (additive-tolerant)
|
||||
- [ ] Integration tests: serving e2e round-trip, FORBIDDEN gate,
|
||||
malformed-params teardown; unit tests for the reply reader
|
||||
- [ ] Clippy/fmt clean; wasm32 check passes
|
||||
- [ ] `cargo test` green
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
|
||||
(§3 the forwarded op — the opener-side serving shape; Amendment 1)
|
||||
- docs/architecture/producer.md §The Forwarded Op; consumer.md §The
|
||||
Listen Flow; wire.md §The Forwarded Op
|
||||
- docs/architecture/decisions/006-access-control-posture.md Amendment 1
|
||||
- tasks/tunnels/bindfirst-params.md (the types);
|
||||
tasks/tunnels/bindfirst-producer.md (the caller half — the tests
|
||||
compose)
|
||||
|
||||
## Notes
|
||||
|
||||
- **`ForwardedHandler` (the injected assembly handler):** `Arc<dyn
|
||||
Fn(TunnelForwardedParams, BiStream) -> tokio::task::JoinHandle<()> +
|
||||
Send + Sync>` — the correlation material the opener-side handler
|
||||
yields per forwarded open. The assembly owns the pump (the honest
|
||||
split: the local halves — the SOCKS5 BIND client's socket — are
|
||||
assembly state), correlating via the `listen` token against its own
|
||||
waiting-listener map; the protocol provides the op + params, not the
|
||||
wait state machine (ADR-008 §3's posture, realized).
|
||||
- **`make_forwarded_op_handler` (the built-in wrapper):** parses
|
||||
`TunnelForwardedParams` (`deny_unknown_fields` + the landed
|
||||
positive-`listen` serde pin), accepts the channel `BiStream`
|
||||
(yield-once; accepting BEFORE the assembly handler runs keeps the
|
||||
R-02 accepted-flag telemetry truthful — a parse-gated early return
|
||||
never accepted, which the wrapper's birth-teardown hint reports),
|
||||
then awaits the injected handler's `JoinHandle` inline (R-02 — the
|
||||
forwarded channel's lifetime tracks it; the wrapper's tracking reaps
|
||||
on handler exit).
|
||||
- **No establisher** — `register_openable` exactly (the forwarded
|
||||
channel needs no establishment; the data plane starts immediately —
|
||||
ADR-008 §3). The wrapper's `channel_id` allocation IS the reply (the
|
||||
producer adopts it) — nothing to implement, upstream machinery.
|
||||
- **Identity posture (upstream machinery, test-pinned):** the
|
||||
opener-side gate sees the PRODUCER's per-call identity (CF-005
|
||||
precedence — payload token > `ServingConfig.identity` >
|
||||
transport); the `peer` param is producer-asserted metadata, not
|
||||
transport truth. Nothing implemented here; the FORBIDDEN probe
|
||||
pins the gate runs (identity without the grant → FORBIDDEN with the
|
||||
missing scope named, never implied by `tunnel:open` — probed with a
|
||||
`tunnel:open`-only identity).
|
||||
- **Correlation-miss semantics (documented + test-pinned):** the
|
||||
`listen`-token miss is the ASSEMBLY's rejection (it holds the map):
|
||||
the injected handler's error path drops the stream and returns —
|
||||
teardown-at-birth reaps the forwarded channel. The producer's accept
|
||||
loop drops the accepted connection on the open's failure (the shapes
|
||||
mirror; pinned producer-side in `tunnels/bindfirst-producer`'s
|
||||
per-accept isolation).
|
||||
- **Reply reader lands in `src/params.rs`** as `listen_reply_fields`
|
||||
(the lenient, infallible session-surface projection) DELEGATING to
|
||||
the landed `parse_listen_reply` (the loud typed viewer): unknown
|
||||
extra reply fields tolerated (the additive posture — no deny-unknown
|
||||
gate on the reply), a malformed shape logs `tracing::error!` loud
|
||||
and surfaces both-`None` (the session presents `Option` fields and
|
||||
never refuses construction over an informational field; use
|
||||
`parse_listen_reply` directly when the loud typed distinction
|
||||
matters). Both `Option` — absent for dial/accept-first producers;
|
||||
the projection is unit-testable independent of the session type
|
||||
(this task's placement rationale), consumed by `TunnelListener`
|
||||
(`tunnels/tunnel-listener`, pending).
|
||||
- **Test-data-plane shape (the harness):** the e2e drives the
|
||||
forwarded channel through BOTH pumps — the opener-side assembly
|
||||
pump pops correlated duplex halves for the token (pushed BEFORE the
|
||||
forwarded open — the waiting-session posture; a late push is the
|
||||
miss shape, probed separately) and the producer-side test adopts the
|
||||
opener-allocated ID and pumps it against a driving echo duplex —
|
||||
then round-trips bytes from the client end through the whole path
|
||||
(assembly pump → forwarded channel → adopted pump → driving echo →
|
||||
back). The malformed-params probes pin the no-phantom property at
|
||||
both gates: `listen: 0` / missing `peer` fail the registry's schema
|
||||
(`INVALID_INPUT`, no channel ever); an unknown top-level field
|
||||
passes the schema (additive posture) but fails the serde parse →
|
||||
the channel (whose ID the reply already carried) reaps promptly.
|
||||
- **Harness note:** the suite reuses the existing harness's
|
||||
`wire_with` + `Topology.consumer_registry` (the
|
||||
`bindfirst-producer` shape) — no harness changes needed; the
|
||||
forwarded-op serving stub helpers (`make_forwarded_stub_handler`
|
||||
etc.) stay as-is (they remain the producer-leg stub stand-in; the
|
||||
real opener-side registration is this task's, exercised directly).
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the forwarded op's SERVING side (the listen-opener half,
|
||||
ADR-008 §3 as amended) per the task: `src/producer.rs` —
|
||||
`ForwardedHandler` (the injected assembly handler: parsed params +
|
||||
channel `BiStream`; the assembly owns the pump, correlating by the
|
||||
`listen` token against its waiting-listener bookkeeping);
|
||||
`make_forwarded_op_handler` (the built-in no-establisher wrapper:
|
||||
deny-unknown params parse — a malformed open fails loud → early
|
||||
return → teardown-at-birth reaps the channel, no phantom; `accept_bi`
|
||||
FIRST per R-02 telemetry; the injected handler's `JoinHandle` awaited
|
||||
inline); `register_tunnel_forwarded_openable` (`register_openable` —
|
||||
no establisher, the data plane starts immediately; scope gate
|
||||
`tunnel:forwarded` via `tunnel_forwarded_spec`). `src/params.rs` —
|
||||
`listen_reply_fields` (the lenient, infallible reply projection:
|
||||
unknown extra fields tolerated, a malformed shape logs loud and
|
||||
surfaces both-`None`; shared with `TunnelListener`), delegating to the
|
||||
landed `parse_listen_reply`. Re-exported through `lib.rs`.
|
||||
|
||||
Tests: 4 new unit tests in `params.rs` (full/bare/extra-field/malformed
|
||||
reply projection) + 4 integration tests in `tests/producer_forwarded.rs`
|
||||
(the serving-half e2e round-trip gate — reply projection → token
|
||||
correlation → forwarded open → byte round-trip through the assembly
|
||||
pump + the producer-side adopted pump; the FORBIDDEN scope gate —
|
||||
`tunnel:open`-only identity, no channel allocated, handler never ran;
|
||||
malformed params — both gates pinned loud, the serde-gated shape
|
||||
tears down at birth; the correlation-miss teardown — the assembly's
|
||||
rejection, drop the stream, the channel reaps, the invocation
|
||||
witnessed). Verified: `cargo test` green (126 default; 146
|
||||
`--all-features`), clippy `--all-targets -D warnings` clean (native +
|
||||
wasm32), fmt clean, `wasm32-unknown-unknown` check passes (pure
|
||||
protocol code; the default crate stays wasm-clean), `cargo doc
|
||||
--no-deps` clean. CHANGELOG updated (Unreleased → Added).
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
id: tunnels/local-bind-halves
|
||||
name: local feature — TCP bind halves + the bind-first assembly pieces
|
||||
status: completed
|
||||
depends_on: [tunnels/bindfirst-producer]
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [local, tcp, bind, listener, feature-gated]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Extend the `local` feature (non-wasm, `tokio/net`-gated —
|
||||
`tunnels/local-socket-halves` established the pattern) with the
|
||||
bind-first shape's substrate half: a real TCP bind function
|
||||
(`BindFn`-compatible) and an accept loop producing
|
||||
`(peer, TargetHandle)` pairs, plus the direct-op convenience dial
|
||||
closure composition (the `DialFn` the direct op shares with the base
|
||||
op — likely already satisfied by `local_dial`; verify, don't
|
||||
duplicate).
|
||||
|
||||
### The bind half
|
||||
|
||||
```rust
|
||||
/// The real-socket BindFn source (the `local` feature's shape 3
|
||||
/// backing): bind `addr` (port 0 = OS-chosen), report the REAL
|
||||
/// bound address (the `bound` reply field's source — ADR-008 §2),
|
||||
/// expose the accept loop that feeds the dispatcher.
|
||||
pub async fn bind_tcp_first(addr: &str)
|
||||
-> Result<BoundListener, TunnelEstablishError>;
|
||||
|
||||
pub fn local_bind_first() -> BindFn; // renders via bind_tcp_first
|
||||
```
|
||||
|
||||
- `BoundListener.listener` carries the `TcpListener` (type-erased in
|
||||
the plan — `tunnels/bindfirst-producer`'s shape note); the accept
|
||||
loop is assembly code composed ON it — the protocol crate stays
|
||||
socket-blind, so the loop lives here (`local` = substrate code,
|
||||
ADR-004):
|
||||
- **`TcpListenerHalves` already has an accept loop** (`accept_loop`
|
||||
feeding an `AcceptQueue` — the accept-first shape). The bind-first
|
||||
shape needs a DIFFERENT loop: per-accept it must produce
|
||||
`(peer_addr, TargetHandle)` for the accept dispatcher (which opens
|
||||
forwarded channels per accept), not feed a queue for the
|
||||
establisher to pop. Add a sibling method/struct (e.g.
|
||||
`TcpListenerHalves::accept_pairs` or a new
|
||||
`BindFirstAcceptLoop` adapter) — decide the honest shape, document
|
||||
in Notes. The peer address rides from `accept()`'s return value
|
||||
(reply#2 fidelity's source — ADR-008 §3's `peer` carrier).
|
||||
- The direct-op `DialFn`: `local_dial()` already renders
|
||||
`(Substrate, backing)` → halves with the UDP framed adapter — the
|
||||
direct establisher calls the SAME closure with the rendered target
|
||||
string. Verify the UDP wrap happens INSIDE the closure (it does —
|
||||
review 001 U-1) so direct-udp gets the framed adapter free; add a
|
||||
test pinning direct-udp-through-`local_dial` framing if missing.
|
||||
|
||||
### Tests (`tests/`, real sockets — the `local` suite's posture)
|
||||
|
||||
- Bind port 0 → `bound` reports the REAL OS-chosen address (the
|
||||
reply#1 fidelity pin).
|
||||
- Two clients dial the bound address → two accepts → two forwarded
|
||||
channels (via the bind-first harness) → both pump independently
|
||||
(peer fidelity: each forwarded open carries the REAL accepted
|
||||
peer).
|
||||
- Unix-domain bind (OQ-TN-14 parity): `bind_unix_first`-equivalent —
|
||||
decide whether unix bind-first ships in this task or is deferred
|
||||
with a note (the wire enum already carries unix; the local dial
|
||||
path exists — the bind path is the same shape; prefer shipping
|
||||
both, matching dial parity).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `bind_tcp_first` + the accept-pairs loop (peer + halves per
|
||||
accept) behind `local`
|
||||
- [x] The bound address is the REAL OS-chosen one (port 0 resolved —
|
||||
pinned by test)
|
||||
- [x] Direct-op `DialFn` reuse verified (no duplicate dial code;
|
||||
UDP framed-adapter wrap composes)
|
||||
- [x] Unix bind-first parity decided + shipped or explicitly deferred
|
||||
with rationale
|
||||
- [x] `cargo test --all-features` green; default-crate wasm32 checks
|
||||
still clean (the feature gate holds)
|
||||
- [x] Clippy/fmt clean (both feature sets)
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
|
||||
(§1 shape 3 — the bind-first establisher; §3 the peer carrier)
|
||||
- docs/architecture/producer.md §The Registration API (`BindFn` shape)
|
||||
- tasks/tunnels/local-socket-halves.md (the `local` pattern + the
|
||||
existing `bind_tcp`/`TcpListenerHalves`/`AcceptQueue` accept-first
|
||||
shapes)
|
||||
- tasks/tunnels/bindfirst-producer.md (the `BindFn`/
|
||||
`BindFirstAcceptLoop` contracts this implements)
|
||||
- OQ-TN-14 (unix ships with `local` v1; stdio out — alktty's)
|
||||
|
||||
## Notes
|
||||
|
||||
- **The task sketch's `bind_tcp_first(addr: &str)` signature held** —
|
||||
the committed [`BindFn`] contract takes `(substrate, backing)`; the
|
||||
substrate dispatch lives inside [`local_bind_first`] (tcp →
|
||||
[`bind_tcp_first`], unix → [`bind_unix_first`], udp → fails loud),
|
||||
matching the type-erased closure precedent (the protocol crate stays
|
||||
socket-blind; the closure is the assembly side).
|
||||
- **The accept-pairs shape: a `*_pair` method, not a sibling loop
|
||||
struct** — [`TcpListenerHalves::accept_pair`] (one accept per call →
|
||||
`(peer, halves)`). The task floated `accept_pairs` or a
|
||||
`BindFirstAcceptLoop` adapter struct; one method is the honest shape:
|
||||
the DISPATCHER owns the loop pacing (the committed
|
||||
`BindFirstAcceptLoop` contract is one closure call = one accept), so
|
||||
the substrate half is just the single-accept step with the peer
|
||||
attached. `local_bindfirst_accept_loop()` is the adapter — it wraps
|
||||
the `*_pair` methods with the downcast (the assembly side of the
|
||||
type-erased injection; a foreign listener type fails loud as
|
||||
`handler_error` — pinned by test). The accept-FIRST
|
||||
[`TcpListenerHalves::accept_loop`] (queue-feeding) is untouched.
|
||||
- **UDP bind-first is NOT here — refused loud** (a decision the task
|
||||
left open): a UDP socket has no accept, so the bind-first shape is
|
||||
not DEFINED for udp; [`local_bind_first`] maps `Substrate::Udp` to a
|
||||
`handler_error` rather than faking a listener (pinned by
|
||||
`udp_bind_first_fails_loud`). The per-peer associate shape (an
|
||||
accept-analogue for UDP) would be a wire/protocol design decision —
|
||||
an ADR, if it ever ships. The direct/forward udp dial shapes cover
|
||||
the substrate.
|
||||
- **Unix bind-first SHIPPED** (the task's preferred posture — OQ-TN-14
|
||||
dial parity): `bind_unix` + `UnixListenerHalves` (a sibling of
|
||||
[`TcpListenerHalves`]: `local_addr`, the queue-feeding
|
||||
`accept_loop`, and the bind-first `accept_pair`) +
|
||||
[`bind_unix_first`]. The bind-first `accept_pair`'s peer is the
|
||||
ACCEPTED client's bound path; an unnamed (autobind) client fails
|
||||
loud — the forwarded op's `peer` param is required on the wire, an
|
||||
addressless peer cannot be represented (ADR-008 §3). The unix
|
||||
parity test binds the CLIENT to a named path first (socket2 — std
|
||||
has no `UnixStream::bind`), making the peer representable.
|
||||
- **`bind_unix` maps bind failure to `ResourceShortage`** (matching
|
||||
[`bind_tcp`]'s posture — the held-resource class), not
|
||||
`DialFailed`: the bind IS the dial for the establisher's error table
|
||||
(shape-3: unbindable/held → `dial_failed`-class rides the
|
||||
establisher's passthrough), but the substrate function's own class
|
||||
follows the sibling bind functions.
|
||||
- **Direct-op `DialFn` reuse: verified, no duplication** —
|
||||
[`local_dial`] is unchanged and shared; the UDP wrap happens INSIDE
|
||||
the closure (review 001 U-1). The missing pin was added:
|
||||
`direct_udp_through_local_dial_round_trips_framed` (tests/
|
||||
local_halves.rs) drives `TunnelSession::open_direct` +
|
||||
`Substrate::Udp` through [`local_dial`] against a REAL udp echo
|
||||
socket — datagram boundary preserved through the direct op's
|
||||
establisher path.
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the `local` feature's bind-first substrate half (shape 3,
|
||||
ADR-008 as amended) per the task: `src/local/mod.rs` — `bind_tcp_first`
|
||||
(the real-socket [`BindFn`] source: port 0 resolved via `local_addr`,
|
||||
the `bound` reply field's data), `TcpListenerHalves::accept_pair` (one
|
||||
accept → `(peer, halves)` — the peer rides `accept()`'s return, the
|
||||
forwarded op's `peer` carrier), `bind_unix` + `UnixListenerHalves` +
|
||||
`bind_unix_first` (unix bind-first parity SHIPPED — OQ-TN-14; unnamed
|
||||
peer fails loud, the forwarded `peer` param requires an address),
|
||||
`local_bind_first()` (the [`BindFn`] convenience: tcp/unix dispatch,
|
||||
udp fails loud — no accept on a UDP socket, the shape is not defined
|
||||
there, never a fake listener) and `local_bindfirst_accept_loop()` (the
|
||||
[`BindFirstAcceptLoop`] convenience: downcasts the plan's opaque
|
||||
listener to the module's wrappers, one pair per call; a foreign
|
||||
listener fails loud). The accept-FIRST shapes (`accept_loop` +
|
||||
`AcceptQueue`) are untouched.
|
||||
|
||||
Tests: `tests/local_bindfirst.rs` (4, `#![cfg(feature = "local")]`) —
|
||||
the tcp e2e over REAL sockets (bind port 0 → the reply's `bound` is
|
||||
the REAL OS-chosen address the test then DIALS — a fake bound would
|
||||
strand the dial; two clients → two accepts → two forwarded channels →
|
||||
independent interleaved pumps, each forwarded open carrying the REAL
|
||||
accepted peer, distinct ports), unix parity (real socket path in
|
||||
`bound`; a named-path client connects; the forwarded open carries the
|
||||
REAL peer path), the udp refusal, and the downcast-miss probe. Plus
|
||||
`direct_udp_through_local_dial_round_trips_framed` in tests/
|
||||
local_halves.rs — the direct-op `DialFn` reuse pin (the framed udp
|
||||
adapter composes through the direct op; review 001 U-1).
|
||||
|
||||
Verified: `cargo test` (default) and `cargo test --all-features` green
|
||||
(46 lib + 151 integration total; the `local` suites 20 + 4), clippy
|
||||
`--all-targets --all-features -D warnings` clean, fmt clean,
|
||||
`wasm32-unknown-unknown` check passes (the default crate stays
|
||||
wasm-clean; the bind-first half lives behind `local`, non-wasm by
|
||||
design).
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
id: tunnels/review-002-coverage-gaps
|
||||
name: "G-4 + G-5 — close the remaining cheap coverage gaps (unix accept-loop test, unnamed unix fail-loud guards)"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [tests, local, unix, coverage, review-002, review-remediation]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
The two remaining cheap-to-close coverage gaps from review 002's
|
||||
coverage appendix (`docs/reviews/
|
||||
002-graduation-implementation-review.md`), batched — both are
|
||||
`local`-feature test additions (no protocol-layer changes).
|
||||
|
||||
### G-4 — `UnixListenerHalves::accept_loop` never driven
|
||||
|
||||
`src/local/mod.rs:591-609` has zero coverage: no test constructs it
|
||||
(the unix dial test hand-rolls its own echo listener; the bind-first
|
||||
unix test uses `accept_pair`). It is the unix parity twin of the
|
||||
covered `TcpListenerHalves::accept_loop` (hit at ~107-125) — 15 lines
|
||||
of the same shape, and the largest single uncovered block in the
|
||||
crate.
|
||||
|
||||
Test: mirror `tests/producer_listen.rs`'s
|
||||
`accept_loop_stops_feeding_after_close_and_maps_the_drop` with
|
||||
`bind_unix` + a temp path — closes the entire 593-607 range and
|
||||
exercises `UnixListenerHalves` beyond the bind-first path. Low value
|
||||
(copy-shaped) but the largest single uncovered block.
|
||||
|
||||
### G-5 — the unnamed-peer/unnamed-bind fail-loud arms (unix)
|
||||
|
||||
Two fail-loud guards the ADR pins (ADR-008 §3 — "a producer that
|
||||
cannot determine the peer does not serve a bind-first listener over
|
||||
that substrate") never fire:
|
||||
|
||||
- `src/local/mod.rs:626-633` — the `accept_pair` unnamed-peer
|
||||
`handler_error` (an autobind client)
|
||||
- `src/local/mod.rs:578-583` — the `local_addr` unnamed-bind error
|
||||
|
||||
The existing unix bind-first test binds a named client socket
|
||||
deliberately (the parity case); no test binds an abstract/autobind
|
||||
unix listener.
|
||||
|
||||
Test: bind an abstract unix listener (Linux: a leading-`\0` path via
|
||||
`socket2` — already a `local` dependency, or `UnixListener::bind` on
|
||||
an unnamed autobind socket), call `local_addr()` expecting the
|
||||
"no filesystem path" `handler_error`, and drive one autobind-client
|
||||
accept through `accept_pair` expecting the unnamed-peer error. Cheap,
|
||||
deterministic, and it pins the two ADR sentences the doc comments
|
||||
promise.
|
||||
|
||||
### Explicitly NOT in this task (review guidance)
|
||||
|
||||
- **G-7** — kernel-dependent readiness arms; leave uncovered.
|
||||
- **G-6** — the UDP empty-resolve arm; only if the `ResolveFn` seam
|
||||
refactor is ever wanted (optional, separate decision — do not do it
|
||||
here).
|
||||
- **G-1/G-3** — unreachable-by-construction serde/JoinError arms;
|
||||
leave uncovered.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] The unix `accept_loop` test added (G-4 — the 591-609 range
|
||||
covered)
|
||||
- [ ] The abstract-listener test added (G-5 — both fail-loud arms
|
||||
covered: `local_addr` unnamed-bind + `accept_pair`
|
||||
unnamed-peer)
|
||||
- [ ] `cargo test --features local` green; clippy `--all-targets
|
||||
-D warnings` clean (native + wasm32); fmt clean; the default
|
||||
crate stays wasm-clean
|
||||
|
||||
## References
|
||||
|
||||
- docs/reviews/002-graduation-implementation-review.md §G-4, §G-5
|
||||
(the coverage appendix)
|
||||
- src/local/mod.rs:591-609 (`UnixListenerHalves::accept_loop`),
|
||||
578-583 (`local_addr` unnamed-bind), 626-633 (`accept_pair`
|
||||
unnamed-peer)
|
||||
- tests/producer_listen.rs (the accept-loop test shape to mirror)
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md §3
|
||||
(the fail-loud sentences the tests pin)
|
||||
|
||||
## Notes
|
||||
|
||||
> Agent fills during implementation.
|
||||
|
||||
### Implementation (2026-09-20)
|
||||
|
||||
**G-4** — `unix_accept_loop_stops_feeding_after_close_and_maps_the_drop`
|
||||
in `tests/producer_listen.rs` (mirroring the TCP twin above it):
|
||||
`bind_unix` + a temp path under the test's own pid-scoped dir, the
|
||||
listener's `accept_loop` spawned against a pre-closed `AcceptQueue`,
|
||||
one unix client connect, and the loop's `handler_error` exit asserted
|
||||
within a 5s bound. Covers the full 591-609 range (the unix
|
||||
accept-first loop was previously never driven).
|
||||
|
||||
**G-5** — `unix_unnamed_bind_and_unnamed_peer_fail_loud` in
|
||||
`tests/local_bindfirst.rs` (`#[cfg(target_os = "linux")]` — abstract
|
||||
addresses + autobind are Linux-shaped). Both fail-loud arms pinned via
|
||||
the crate's own public API (no private-constructor seam needed):
|
||||
|
||||
- **Unnamed bind**: `bind_unix("")` — tokio passes the empty path
|
||||
through to `bind(2)`, which the kernel autobinds (addlen ==
|
||||
`offsetof(sun_path)`, an abstract name, no filesystem path) —
|
||||
`local_addr` maps it to the "no filesystem path" handler error
|
||||
(the 578-583 arm). Probed on this host: the bound addr reports
|
||||
`as_pathname() == None`.
|
||||
- **Unnamed peer**: a socket2 client bound to the empty abstract
|
||||
sockaddr (the autobind shape — verified `Ok` on this kernel)
|
||||
connects to a NAMED `bind_unix` listener; `accept_pair` refuses
|
||||
with the unnamed-peer handler error (the 626-633 arm). A plain
|
||||
unbound client behaves identically: mio's UDS accept maps ANY
|
||||
abstract-namespace peer (`sun_path[0] == 0`, which includes the
|
||||
autobind assignment) to an unnamed addr, so the fail-loud guard
|
||||
catches both client shapes through tokio's accept.
|
||||
|
||||
`bind_unix_first` itself stays untested for the unnamed-bind arm at
|
||||
the establisher level (it composes `bind_unix` + `local_addr` — the
|
||||
arm is the same code path the test drives directly); the local_addr
|
||||
assertion covers the composition's error leg.
|
||||
|
||||
**Verification** (all green):
|
||||
|
||||
- `cargo test` (135) + `cargo test --features local` (162 passed,
|
||||
0 failed)
|
||||
- `cargo clippy --all-targets -- -D warnings` (default + `local`)
|
||||
- `cargo fmt --check`
|
||||
- `cargo check` + `cargo clippy -D warnings --target
|
||||
wasm32-unknown-unknown` (the default crate stays wasm-clean)
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
id: tunnels/review-002-doc-sync
|
||||
name: "D-1 + D-2 — sync wire.md's schema bounds + ADR-007's oneOf→if/then + the establisher-time→registry-gate sentence"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: project
|
||||
level: implementation
|
||||
tags: [docs, wire-format, adr-007, review-002, review-remediation]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Two doc-only review 002 findings
|
||||
(`docs/reviews/002-graduation-implementation-review.md` §D-1, §D-2)
|
||||
batched: the shipped code is the wire-stable side and is correct; the
|
||||
*docs* lag it. Wire-contract-adjacent — do this before the first
|
||||
consumer (alksocks) ships.
|
||||
|
||||
### D-1 — wire.md omits the `port` bounds the shipped schema enforces
|
||||
|
||||
The shipped direct-op and forwarded-op schemas pin
|
||||
`"port": {"minimum": 0, "maximum": 65535}` plus
|
||||
`"additionalProperties": false` on the target/peer objects
|
||||
(`src/params.rs:208-222, 453-467`), and `listen` with
|
||||
`"minimum": 1` (`src/params.rs:442`). wire.md (the normative WHAT)
|
||||
says only `port: <u16>` and "integer > 0" in prose — it does not
|
||||
record the bounds as schema properties, nor the
|
||||
`additionalProperties: false` gate that makes cross-shape targets
|
||||
fail the registry's `INVALID_INPUT` class BEFORE the establisher (the
|
||||
behavior `malformed_target_is_schema_class_and_leaves_no_channel`
|
||||
pins on the wire). The BAST doc (`bast.md`) covers only the
|
||||
data-plane codec, not the params schemas — no second source records
|
||||
these bounds.
|
||||
|
||||
Fix: record the exact schema constraints in wire.md's §The Direct Op
|
||||
and §The Forwarded Op (the `port` bounds, `additionalProperties:
|
||||
false` per branch, the `listen` `minimum: 1`, and the gate-ordering
|
||||
sentence). The risk addressed: a future consumer implementing from
|
||||
wire.md alone accepting a shape the reference producer rejects (or
|
||||
vice versa).
|
||||
|
||||
### D-2 — ADR-007's `oneOf` wording is stale; the "validated at establisher time" sentence contradicts the shipped design
|
||||
|
||||
1. ADR-007 §2 (line ~82) says "the target sub-schema is per-substrate
|
||||
(`oneOf` on the sibling `substrate` value, validated at establisher
|
||||
time — see Consequences)". The shipped schema (and wire.md §The
|
||||
Forwarded Op, and the unit tests pinning it) use **`allOf` of two
|
||||
`if`/`then` branches** instead. `oneOf` of full-target-schemas
|
||||
would actually be WRONG here (a `unix`-substrate open would need to
|
||||
fail the `tcp` branch — plain `oneOf` on sibling-conditioned
|
||||
subschemas is awkward; `if`/`then` composes correctly with the base
|
||||
`properties`/`required`). The code is right; the ADR's one-word
|
||||
schema-structure reference is stale.
|
||||
2. The same line's "validated at establisher time" contradicts the
|
||||
shipped design and wire.md's own text: the test
|
||||
(`malformed_target_is_schema_class_and_leaves_no_channel`) proves
|
||||
the REGISTRY gate fires first (the establisher's dial never runs).
|
||||
The ADR/wire.md sentence should read "validated registry-side (the
|
||||
schema gate), before the establisher; re-checked by serde at
|
||||
establisher time as the backstop" — which is exactly what
|
||||
`src/params.rs`'s doc comments already say.
|
||||
|
||||
Fix: a one-line ADR-007 erratum (or an amendment note — house style
|
||||
has precedent: ADR-008 carries numbered amendments) replacing
|
||||
`oneOf` with the shipped `allOf`-of-`if`/`then` structure, plus the
|
||||
corrected gate-ordering sentence; mirror the same
|
||||
gate-ordering correction in wire.md §The Direct Op's
|
||||
"validated at establisher time" sentence if it says the same thing
|
||||
(verify both files while in there). No code changes.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] wire.md records the shipped schema bounds (`port`
|
||||
min/max, `additionalProperties: false`, `listen`
|
||||
`minimum: 1`) in both op sections
|
||||
- [ ] ADR-007 §2 corrected: `oneOf` → the shipped `allOf`/
|
||||
`if`/`then` structure (erratum or amendment, house style)
|
||||
- [ ] The "validated at establisher time" sentence corrected to
|
||||
"registry-side schema gate first; serde backstop at
|
||||
establisher time" in ADR-007 (and wire.md if it repeats it)
|
||||
- [ ] `cargo doc --no-deps` clean (docs changed — the verification
|
||||
battery's doc gate)
|
||||
|
||||
## References
|
||||
|
||||
- docs/reviews/002-graduation-implementation-review.md §D-1, §D-2
|
||||
- docs/architecture/wire.md (§The Direct Op, §The Forwarded Op)
|
||||
- docs/architecture/decisions/007-direct-open-op.md (§2, line ~82)
|
||||
- src/params.rs:208-222, 442, 453-467 (the shipped schemas the docs
|
||||
must match)
|
||||
|
||||
## Notes
|
||||
|
||||
> Agent fills during implementation.
|
||||
|
||||
## Implementation Notes (2026-09-20)
|
||||
|
||||
- **D-1 (wire.md §The Direct Op + §The Forwarded Op)**: both sections
|
||||
now record the exact shipped schema constraints — the per-branch
|
||||
target/peer objects (`type: object`, `additionalProperties: false`,
|
||||
`port` bounds `minimum: 0`/`maximum: 65535`, the `unix` `path`
|
||||
branch) and the forwarded `listen` `minimum: 1` + its serde-pin
|
||||
backstop — with the gate-ordering sentence (registry-side schema
|
||||
gate before the establisher, serde backstop) and the
|
||||
cross-shape-INVALID_INPUT behavior the integration test pins. No
|
||||
second source existed for these bounds (bast.md covers only the
|
||||
data-plane codec); wire.md now is the complete record.
|
||||
- **D-2 (ADR-007 §2)**: the stale sentence corrected in place
|
||||
(`oneOf` → the shipped `allOf` of two `if`/`then` branches;
|
||||
"validated at establisher time" → registry-side gate + serde
|
||||
backstop), plus a dated **Erratum** section documenting why `oneOf`
|
||||
would actually be wrong (exactly-one-branch-matches does not
|
||||
compose with the base `properties`/`required`) and citing the
|
||||
pinning test. wire.md §The Direct Op's mirrored sentence corrected
|
||||
in the same edit. ADR-008 was checked and needs no change (it never
|
||||
stated the `oneOf`/establisher-time claim).
|
||||
- Verification: `cargo doc --no-deps` clean (0 warnings);
|
||||
`cargo fmt --check` clean.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
id: tunnels/review-002-g2-graceful-teardown-observation
|
||||
name: "G-2 — observe close()'s EOF sentinel at the far accept dispatcher (+ N-2's write-happy-peer teardown pin)"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [tests, consumer, bind-first, teardown, review-002, review-remediation]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Review 002 coverage gaps G-2 and the N-2 branch note, batched (the
|
||||
review itself suggests the same harness shape covers both — see its
|
||||
§Branch-coverage notes, last bullet).
|
||||
|
||||
### G-2 — `TunnelListener::close`'s `shutdown` arm never observed at the far end
|
||||
|
||||
`src/consumer.rs:529-531`: `close()` runs `write.shutdown().await`
|
||||
(the explicit EOF sentinel) then reaps — but no test currently
|
||||
observes the FAR accept dispatcher's idle-read firing from a `close()`
|
||||
(the graceful path). The Drop path (implicit EOF → far teardown) IS
|
||||
observed end-to-end
|
||||
(`bindfirst_open_surfaces_reply_fields_and_teardown_cascades`), and
|
||||
the close path is pinned only at the reap level
|
||||
(`close_and_drop_reap_the_listen_channel`) — the sentinel's far-side
|
||||
effect for `close()` specifically is asserted nowhere: the "the pushed
|
||||
accept was never consumed" assertion runs only in the Drop-shaped
|
||||
tests.
|
||||
|
||||
**This is the only coverage gap touching a *promised* behavior**
|
||||
(ADR-008 §1's teardown contract, graceful half) with no direct
|
||||
observation.
|
||||
|
||||
Test: mirror `bindfirst_full_flow…`'s harness (the
|
||||
`tests/consumer_listener.rs` bind-first topology) with a `close()`
|
||||
instead of a drop — open against a bind-first producer, push an
|
||||
accept, `listener.close().await`, then assert the far accept loop is
|
||||
gone (the same queue-len / never-consumed assertion the Drop tests
|
||||
use) and the serving-side listen channel reaped.
|
||||
|
||||
### N-2's testable residue — the write-happy-peer teardown posture
|
||||
|
||||
The idle-stream EOF watch (`src/producer.rs:781-794`) triggers
|
||||
teardown on `Ok(0)`, `Err(_)`, AND `Ok(1..)` — a misbehaving opener
|
||||
that writes to the idle listen channel also tears the listener down
|
||||
(fail-closed; the channel is not a data plane; the doc comment owns
|
||||
this). N-2 records the shape as correct, but the review's
|
||||
branch-coverage notes flag that the `Ok(_)` (non-empty read) arm is
|
||||
untested: a test would write a byte to the idle channel and assert
|
||||
far teardown, pinning the "any non-empty read tears down" posture.
|
||||
|
||||
Test: same harness — open the listen channel, write a byte into the
|
||||
idle stream from the opener side, assert the accept loop cancels and
|
||||
the listener tears down (the fail-closed posture).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] The graceful-`close()` far-teardown test added (the sentinel's
|
||||
far-side effect observed)
|
||||
- [x] The write-happy-peer teardown test added (the `Ok(_)` idle-read
|
||||
arm pinned)
|
||||
- [x] `cargo test --features local` green; clippy `--all-targets
|
||||
-D warnings` clean (native + wasm32); fmt clean
|
||||
|
||||
## References
|
||||
|
||||
- docs/reviews/002-graduation-implementation-review.md §G-2 (Coverage
|
||||
appendix), §N-2, and §Branch-coverage notes (last bullet)
|
||||
- src/consumer.rs:529-531 (`TunnelListener::close`), src/producer.rs
|
||||
(the idle-stream EOF watch)
|
||||
- tests/consumer_listener.rs (the harness shape to mirror)
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md §1
|
||||
(the teardown contract's graceful half)
|
||||
|
||||
## Notes
|
||||
|
||||
> Agent fills during implementation.
|
||||
|
||||
Filled (2026-09-20):
|
||||
|
||||
- Both tests landed in `tests/consumer_listener.rs` — no `src/`
|
||||
changes needed (pure coverage additions):
|
||||
- `graceful_close_eof_sentinel_observed_at_far_accept_dispatcher`
|
||||
(G-2): mirrors the bind-first full-flow harness with a `close()`
|
||||
instead of a drop. Proves the accept loop alive first (a consumed
|
||||
accept), then asserts after `listener.close().await` that the
|
||||
serving-side listen channel reaped AND a follow-on pushed accept
|
||||
is never consumed (the same queue-len assertion the Drop-shaped
|
||||
tests use) — the sentinel's far-side effect for the graceful path.
|
||||
- `write_happy_peer_on_idle_listen_channel_tears_down_far_listener`
|
||||
(N-2 residue): opens the listen channel via the raw client
|
||||
surface (`open_channel_with_reply`, both halves held alive), proves
|
||||
the accept loop alive, writes one byte into the idle stream, and
|
||||
asserts the fail-closed teardown (channel reaped + the follow-on
|
||||
accept never consumed). The typed `TunnelListener` never exposes
|
||||
its idle write half by contract, so the misbehaving-opener shape
|
||||
requires the raw client — that shape difference is the point.
|
||||
- Harness notes: the write-happy test installs NO forwarded-op
|
||||
serving stub — the pre-write consumed accept fails its forwarded
|
||||
open (per-accept isolation), which is fine: the assertion only
|
||||
needs the queue emptied (loop alive), and the isolation path is
|
||||
pinned by `open_only_opener_still_opens_listen_but_forwarded_fails`.
|
||||
The listener session's own `_recv` half is dropped in the
|
||||
write-happy test (the write half alone keeps the pump alive; the
|
||||
recv half is the opener's unused read side of the idle channel).
|
||||
- Verification: `cargo test --features local` green (163 tests across
|
||||
all targets + 2 doc-tests), `cargo clippy --all-targets --features
|
||||
local -- -D warnings` clean, `cargo clippy --target
|
||||
wasm32-unknown-unknown -- -D warnings` clean, `cargo check --target
|
||||
wasm32-unknown-unknown` clean, `cargo fmt --check` clean.
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
id: tunnels/review-002-m1-substrate-fallback
|
||||
name: "M-1 remediation — the accept dispatcher's silent substrate fallback (stamp the plan, fail loud)"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [producer, bind-first, review-002, review-remediation]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Review 002 finding M-1
|
||||
(`docs/reviews/002-graduation-implementation-review.md` §M-1): the
|
||||
bind-first accept dispatcher's substrate extraction
|
||||
(`src/producer.rs:766-768`, inside `make_bindfirst_accept_handler`)
|
||||
silently falls back to `Substrate::Tcp` when the second
|
||||
`serde_json::from_value::<TunnelParams>` parse fails:
|
||||
|
||||
```rust
|
||||
let substrate = serde_json::from_value::<TunnelParams>(input.clone())
|
||||
.map(|p| p.substrate)
|
||||
.unwrap_or(Substrate::Tcp);
|
||||
```
|
||||
|
||||
In the wired path the fallback is unreachable (the registry schema gate
|
||||
and the establisher's `parse_params` both validate first), but the
|
||||
shape itself is wrong: a future drift would silently stamp `Tcp` into
|
||||
every forwarded open — for a UDP listen tunnel that is a
|
||||
raw-vs-framed data-plane mismatch on the opener side (the exact
|
||||
invariant class review 001's U-1 was about), degrading silently
|
||||
instead of loudly.
|
||||
|
||||
### The fix (decide deliberately, then implement)
|
||||
|
||||
The review offers two shapes; **the second is recommended** (the
|
||||
review calls it strictly better, and the pre-consumer window is open —
|
||||
`BindFirstPlan` has no external consumer yet):
|
||||
|
||||
1. Log-loud-and-return on parse failure (teardown-at-birth — the
|
||||
same posture as every other early return in the file). Smaller,
|
||||
but keeps the second parse and the fallback branch.
|
||||
2. **Stamp the substrate into `BindFirstPlan` at establisher time and
|
||||
drop the second parse entirely** — one parse, one source of truth,
|
||||
no fallback branch at all. The establisher already parsed the
|
||||
params successfully; the substrate is per-open-static; the plan is
|
||||
already the per-open carrier. `BindFirstPlan` gains a
|
||||
`substrate: Substrate` field (a plan-payload change — cheap now,
|
||||
pre-consumer); the dispatcher reads it from the plan it already
|
||||
downcast.
|
||||
|
||||
If shape 2: the establisher's construction site
|
||||
(`bindfirst_establisher`) carries the parsed substrate into the plan;
|
||||
the per-dispatcher token-discipline test continues to pass; check the
|
||||
`Debug` impl for `BindFirstPlan` still compiles (opaque listener
|
||||
field) and the doc comment on the new field states why it rides the
|
||||
plan (the dispatcher must not re-parse wire input).
|
||||
|
||||
### Tests
|
||||
|
||||
- Keep the existing suite green (the two-open token-discipline test
|
||||
pins per-dispatcher stamping from its own plan — it now also pins
|
||||
the substrate's origin implicitly).
|
||||
- A failure-path test: with shape 2 there is no failure branch left
|
||||
in the dispatcher (assert the removed fallback is gone — e.g. the
|
||||
forwarded opens for a UDP bind-first listen carry `substrate: udp`
|
||||
from the plan, not a re-parse). With shape 1: a malformed-input
|
||||
accept teardown test (loud return, channel reaps, no phantom
|
||||
channel).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] The silent `unwrap_or(Substrate::Tcp)` fallback is gone from
|
||||
`make_bindfirst_accept_handler`
|
||||
- [x] The chosen shape documented in this task's Notes (plan-stamp vs
|
||||
log-and-return, with the rationale)
|
||||
- [x] A test pins the forwarded-op substrate source (plan-stamped, or
|
||||
the loud teardown on parse failure)
|
||||
- [x] `cargo test` green; clippy `--all-targets -D warnings` clean;
|
||||
fmt clean; wasm32 check + clippy clean
|
||||
|
||||
## References
|
||||
|
||||
- docs/reviews/002-graduation-implementation-review.md §M-1 (the
|
||||
finding, both fix shapes, and why not fixed inline)
|
||||
- src/producer.rs — `BindFirstPlan` (~line 97), the establisher, and
|
||||
`make_bindfirst_accept_handler` (~line 755)
|
||||
- tasks/tunnels/bindfirst-producer.md (the original implementation
|
||||
task + Notes on the plan's shape)
|
||||
|
||||
## Notes
|
||||
|
||||
> Agent fills during implementation.
|
||||
|
||||
**Chosen shape: 2 — stamp the plan** (the review's recommendation).
|
||||
Rationale: one parse (the establisher's `parse_params` — which
|
||||
rejects malformed input with `handler_error` before any channel
|
||||
exists), one source of truth; the substrate is per-open-static and
|
||||
the plan is already the per-open carrier; the fallback branch is not
|
||||
just guarded against, it is REMOVED — a future drift cannot degrade
|
||||
silently because there is no fallback to degrade into. Pre-consumer
|
||||
window used (`BindFirstPlan` has no external consumer yet — a
|
||||
plan-payload change is cheap).
|
||||
|
||||
Implemented:
|
||||
|
||||
- `BindFirstPlan` gains `substrate: Substrate` (doc comment states
|
||||
why it rides the plan — the dispatcher must not re-parse wire
|
||||
input; the removed fallback's data-plane mismatch risk is cited).
|
||||
The `Debug` impl stamps it (the opaque listener field stays).
|
||||
- `bindfirst_establisher` stamps the parsed `params.substrate` into
|
||||
the plan at construction time.
|
||||
- `make_bindfirst_accept_handler`: the second
|
||||
`serde_json::from_value::<TunnelParams>` parse and the
|
||||
`unwrap_or(Substrate::Tcp)` fallback are gone; the dispatcher
|
||||
reads `plan.substrate` from the plan it already downcast. The
|
||||
handler's `input` param is now unused (`_input`).
|
||||
|
||||
Tests:
|
||||
|
||||
- Existing suite kept green — the two-open token-discipline test
|
||||
(`token_discipline_two_opens_distinct_tokens`) still pins
|
||||
per-dispatcher stamping from its own plan.
|
||||
- `bindfirst_plan_downcasts_through_channel_plan` (unit): the plan's
|
||||
substrate rides the downcast (`Substrate::Udp` constructed →
|
||||
recovered).
|
||||
- `bindfirst_establisher_flow_end_to_end` (unit): the establisher
|
||||
stamps the parsed substrate into the plan (`Tcp` input →
|
||||
`plan.substrate == Tcp`).
|
||||
- **`forwarded_opens_carry_plan_stamped_substrate` (integration,
|
||||
the M-1 pin):** a UDP bind-first listen over the wire — the
|
||||
forwarded open the dispatcher makes carries `substrate: udp` (the
|
||||
plan's stamp), observed through a substrate-recording
|
||||
forwarded-op stub on the opener side. The removed fallback would
|
||||
have stamped `Tcp`; the assert is `vec![Substrate::Udp]`.
|
||||
|
||||
Docs synced: `docs/architecture/producer.md` (§3 — the plan carries
|
||||
`{listener, token, substrate}`; §The Forwarded Op item 1 — the
|
||||
substrate also comes from the plan, never re-parsed),
|
||||
`docs/architecture/decisions/008-listen-metadata-forwarded-op.md`
|
||||
(§1 — the plan's substrate stamp + the removed fallback, review 002
|
||||
M-1 referenced).
|
||||
|
||||
Verification (all green): `cargo test` (145 tests across the lib +
|
||||
10 integration binaries), `cargo test --all-features` (153),
|
||||
`cargo clippy --all-targets -- -D warnings`,
|
||||
`cargo clippy --all-targets --all-features -- -D warnings`,
|
||||
`cargo fmt --check`, `cargo doc --no-deps`,
|
||||
`cargo check --target wasm32-unknown-unknown` +
|
||||
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`.
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
id: tunnels/review-002-recorder
|
||||
name: "N-1..N-7 — record the review's no-action notes in the code/ADR trail (N-4's ADR line + N-3's telemetry note)"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
risk: trivial
|
||||
impact: isolated
|
||||
level: implementation
|
||||
tags: [docs, notes, review-002, review-remediation]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Review 002's [note] findings
|
||||
(`docs/reviews/002-graduation-implementation-review.md` §N-1..N-7)
|
||||
are correct-as-is with **no code action** — but two of them ask for a
|
||||
durable trail beyond the review doc itself, and the rest are recorded
|
||||
here as the tracker (the review's own remediation list only orders
|
||||
M-1/D-*/the coverage gaps).
|
||||
|
||||
### The two notes wanting a durable trail
|
||||
|
||||
- **N-3** — `make_forwarded_op_handler` parses params BEFORE
|
||||
`accept_bi` (`src/producer.rs:993-1006`), so the R-02 accepted-flag
|
||||
telemetry reads `false` for a malformed-params open — the
|
||||
birth-teardown hint fires for a handled-rejection case. The order
|
||||
is deliberately the better one (accepting first would hold a stream
|
||||
open for a params set the producer already got wrong). Record one
|
||||
sentence in the code's doc comment ("the accepted-flag telemetry
|
||||
reads false here by design — a malformed-params open IS a
|
||||
birth-teardown shape") so a future alkcall reviewer doesn't flag
|
||||
the flag.
|
||||
- **N-4** — `SubstrateAddr`'s untagged leniency is schema-gated (the
|
||||
schema's `additionalProperties: false` per branch is the real
|
||||
gate). Two latent sharp edges to keep in view, in the ADR trail:
|
||||
1. The establisher-level serde backstop keys `render_target` off
|
||||
the sibling `substrate` — a cross-shape target that sneaked past
|
||||
a looser registry would render *something* rather than fail.
|
||||
Worth an ADR line if a future consumer runs a schema-less
|
||||
registry variant. Add the line to ADR-007 (Consequences or the
|
||||
wire-stable section) while the doc-sync task's context is fresh
|
||||
— one sentence: "the serde backstop renders the sibling-keyed
|
||||
target; cross-shape garbage is the schema's absence, not a
|
||||
serde failure."
|
||||
2. If `SubstrateAddr` grows a variant whose shapes overlap (e.g. a
|
||||
future `{host, port, flowlabel}`), untagged disambiguation
|
||||
starts ordering — switch to an internally-tagged form at that
|
||||
point. This is additive-only, pre-consumer; the ADR line should
|
||||
say "don't let it happen by accident."
|
||||
|
||||
### The no-action notes (recorded here as the tracker)
|
||||
|
||||
- **N-1** — `parse_listen_reply`'s positive-`listen` requirement
|
||||
matches the forwarded params' wire contract (`minimum: 1`);
|
||||
JSON-null-as-absent is the standard lenient reading; the struct
|
||||
`deny_unknown_fields` warning in the doc comment is doing real
|
||||
work — keep both. No action.
|
||||
- **N-2** — the idle-stream EOF watch treats ANY byte as teardown
|
||||
(fail-closed). Correct; the ADR sentence is thinner than the
|
||||
implementation — the write-happy-peer test pin is tracked in
|
||||
`tunnels/review-002-g2-graceful-teardown-observation`. Optionally
|
||||
widen ADR-008 §1's "its EOF cancels the loop" sentence to name the
|
||||
any-byte posture while editing (coordinate with
|
||||
`tunnels/review-002-doc-sync` if both touch ADR-008).
|
||||
- **N-5** — the token-mint discipline is sound; the 256-channel
|
||||
per-connection cap makes the wrap-collision math even more
|
||||
favorable than the doc comment states. No action (optionally
|
||||
tighten the doc comment's math note).
|
||||
- **N-6** — the sleep-then-assert test pattern
|
||||
(`tests/consumer_listener.rs:276` + siblings) is the established
|
||||
house pattern; if CI ever flakes, the fix is a bounded poll loop on
|
||||
`channel_ids()` extended to the arrivals. No action now.
|
||||
- **N-7** — `test_bind()`'s bare `Arc<u32>` listener is fine (the
|
||||
downcast-miss probe covers the failure mode). No action.
|
||||
|
||||
### G-1/G-3 — the unreachable-by-construction arms (document-only)
|
||||
|
||||
Per the coverage appendix's recommendations: leave both uncovered,
|
||||
noted here as the review's "unreachable error arm" record —
|
||||
`TunnelSession::open`/`open_direct`/`TunnelListener::open` (+
|
||||
`open_reverse_channel`'s twin) params-serialize closures (serde_json
|
||||
cannot fail on these types) and `TunnelSession::join`'s pump-`Err`
|
||||
arm (the pump handle is never aborted before `join` through the
|
||||
public API). If a shared "unreachable error arm" prose note ever
|
||||
lands in the test suite, these are its members. Optionally collapse
|
||||
G-1's four duplicated closures into one `params_to_input` helper
|
||||
(cosmetic; the review says fine to skip).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] The N-3 telemetry-ordering sentence added near
|
||||
`make_forwarded_op_handler`'s parse (doc comment)
|
||||
- [ ] The N-4 ADR lines added to ADR-007 (the serde-backstop render
|
||||
+ the overlapping-variants caution)
|
||||
- [ ] The N-2 ADR-008 §1 widening applied (or explicitly declined
|
||||
with rationale in Notes)
|
||||
- [ ] The no-action notes' disposition recorded in this task's Notes
|
||||
section (so the tracker carries the review's decisions)
|
||||
|
||||
## References
|
||||
|
||||
- docs/reviews/002-graduation-implementation-review.md §N-1..N-7,
|
||||
§G-1, §G-3
|
||||
- src/producer.rs:993-1006 (`make_forwarded_op_handler`'s
|
||||
parse-before-accept ordering)
|
||||
- docs/architecture/decisions/007-direct-open-op.md,
|
||||
docs/architecture/decisions/008-listen-metadata-forwarded-op.md
|
||||
- src/params.rs:329-339 (`parse_listen_reply`), src/producer.rs:97
|
||||
(`BindFirstPlan`), src/consumer.rs (the unreachable arms)
|
||||
|
||||
## Notes
|
||||
|
||||
> Agent fills during implementation.
|
||||
|
||||
## Implementation Notes (2026-09-20) — the review's no-action dispositions, recorded
|
||||
|
||||
The two durable-trail items landed as specified; the no-action notes'
|
||||
dispositions follow (this section is the tracker the task asked for).
|
||||
|
||||
### Landed
|
||||
|
||||
- **N-3**: the parse-before-accept telemetry sentence added to
|
||||
`make_forwarded_op_handler`'s doc comment
|
||||
(`src/producer.rs`) — "the accepted-flag telemetry reads false here
|
||||
by design; a malformed-params open IS a birth-teardown shape; do
|
||||
not flag the flag (review 002 N-3)".
|
||||
- **N-4**: both ADR-007 Consequences lines added — (1) the
|
||||
`SubstrateAddr` untagged-leniency-is-schema-gated paragraph (the
|
||||
sibling-keyed `render_target` backstop; "cross-shape garbage is the
|
||||
schema's absence, not a serde failure"; the schema-less-registry
|
||||
ADR-line-if-ever requirement) and (2) the overlapping-variants
|
||||
caution (untagged disambiguation starts ordering → switch to an
|
||||
internally-tagged form, deliberately, never by accident).
|
||||
- **N-2 (ADR-008 §1 widening — APPLIED, not declined)**: §1's
|
||||
accept-dispatcher bullet now states the any-byte idle-read posture
|
||||
(`Ok(0)` / error / any non-empty read all tear down; fail closed;
|
||||
the write-happy-peer test pins it) and names the `Ok(0)`-only
|
||||
hang-on-write-happy-peer trap the narrower reading would produce.
|
||||
Note: the doc-sync task and this task both touched ADR files but
|
||||
never ADR-008, so no coordination conflict materialized.
|
||||
- **N-5 (optionally tighten — done)**: `mint_token`'s doc comment now
|
||||
records the review's cap analysis (the token is
|
||||
per-producer-connection; the 256-channels-per-connection cap makes
|
||||
the live-collision window unreachable by a wide margin).
|
||||
|
||||
### No-action dispositions (unchanged from the review)
|
||||
|
||||
- **N-1**: no action. `parse_listen_reply`'s positive-`listen`
|
||||
requirement matches the forwarded params' `minimum: 1`; JSON-null-
|
||||
as-absent is the standard lenient reading; the `deny_unknown_fields`
|
||||
warning in `TunnelListenReply`'s doc comment is doing real work —
|
||||
kept as-is.
|
||||
- **N-2's code**: no action (fail-closed any-byte watch is correct as
|
||||
shipped; only the ADR sentence was widened, above).
|
||||
- **N-6**: no action. Sleep-then-assert is the established house
|
||||
pattern (3x-repeat stable historically); if CI ever flakes on the
|
||||
forwarded-arrival tests, the fix is a bounded poll loop on
|
||||
`channel_ids()` extended to the arrivals — recorded here, not
|
||||
implemented.
|
||||
- **N-7**: no action. `test_bind()`'s bare `Arc<u32>` listener is
|
||||
fine; the downcast-miss probe
|
||||
(`accept_pair_loop_downcast_miss_fails_loud`) covers the failure
|
||||
mode.
|
||||
- **G-1/G-3 (the unreachable-by-construction arms)**: left uncovered,
|
||||
per the review's recommendations — `open`/`open_direct`/
|
||||
`TunnelListener::open` (+ `open_reverse_channel`'s twin)
|
||||
params-serialize closures (serde_json cannot fail on these types)
|
||||
and `join`'s pump-`Err` arm (unreachable through the public API; the
|
||||
only path in is a panicking pump, out of contract). This section is
|
||||
the "unreachable error arm" record the coverage appendix asked for;
|
||||
the optional `params_to_input` helper collapse was skipped
|
||||
(cosmetic; the review says fine to skip).
|
||||
|
||||
### Verification
|
||||
|
||||
- `cargo fmt --check`; `cargo clippy --all-targets -D warnings`
|
||||
(native + wasm32); `cargo test` (all suites + doc-tests);
|
||||
`cargo doc --no-deps` — all green.
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
id: tunnels/review-002-trivial-batch
|
||||
name: "Trivial batch — D-3 (rename the overpromising schema test helper) + D-4 (trim the stale CHANGELOG preamble)"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
risk: trivial
|
||||
impact: isolated
|
||||
level: implementation
|
||||
tags: [tests, changelog, review-002, review-remediation]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Two trivial review 002 findings
|
||||
(`docs/reviews/002-graduation-implementation-review.md`) batched —
|
||||
each a one-line-scale change; no behavior impact.
|
||||
|
||||
### D-3 — the test helper's name overpromises
|
||||
|
||||
`src/params.rs:628-637`: `jsonschema_valid` parses the value with
|
||||
serde and pattern-matches the substrate string — it validates the
|
||||
serde layer, NOT the JSON Schema (the registry compiles the real
|
||||
validators; adding a `jsonschema` dev-dependency purely for this unit
|
||||
test is not warranted). The call-site
|
||||
`schema_rejects_unknown_substrate_value` therefore promises more than
|
||||
it delivers; the sibling
|
||||
`direct_target_schema_gates_shape_per_substrate` is honest (it does
|
||||
structural schema assertions and says so).
|
||||
|
||||
Fix: rename `jsonschema_valid` → `serde_valid` (or inline the check)
|
||||
and rename the call-site test
|
||||
`schema_rejects_unknown_substrate_value` →
|
||||
`serde_rejects_unknown_substrate_value` (its doc comment should say
|
||||
"the serde layer; the registry's real schema gate is pinned
|
||||
end-to-end by the integration probes
|
||||
(`schema_rejects_unknown_substrate_loudly`,
|
||||
`malformed_target_is_schema_class…`, the forwarded `listen: 0`
|
||||
gate)"). Do NOT add a `jsonschema` dev-dependency.
|
||||
|
||||
### D-4 — the CHANGELOG "Unreleased" preamble is stale
|
||||
|
||||
`CHANGELOG.md`'s Unreleased section still opens with "implementation
|
||||
is decomposed in `tasks/tunnels/` and lands in the next release…
|
||||
the only behavior-affecting change so far (no wire or API change at
|
||||
this level yet)" — written before the six graduation implementation
|
||||
commits. The `### Added` section below it correctly lists all six
|
||||
features; the preamble contradicts the section it introduces.
|
||||
|
||||
Fix: trim the preamble to a one-liner (e.g. "The graduation
|
||||
implementation (ADRs 007/008) landed — see Added below." — final
|
||||
wording agent's choice) or drop it.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] The overpromising unit test/helper renamed (no `jsonschema`
|
||||
dev-dependency added)
|
||||
- [ ] The CHANGELOG Unreleased preamble trimmed or dropped
|
||||
- [ ] `cargo test` green (the renamed test included); clippy + fmt
|
||||
clean
|
||||
|
||||
## References
|
||||
|
||||
- docs/reviews/002-graduation-implementation-review.md §D-3, §D-4
|
||||
- src/params.rs:628-637 (the helper + call-site)
|
||||
- CHANGELOG.md (the Unreleased preamble)
|
||||
|
||||
## Notes
|
||||
|
||||
> Agent fills during implementation.
|
||||
|
||||
## Implementation Notes (2026-09-20)
|
||||
|
||||
- **D-3**: inlined the honest serde check directly into the renamed
|
||||
test `serde_rejects_unknown_substrate_value` (the `jsonschema_valid`
|
||||
helper deleted — it had exactly one call-site, and its
|
||||
`matches!`-with-guard body was convoluted beyond its job). The doc
|
||||
comment names the integration probes that pin the real registry-side
|
||||
gate end-to-end. No `jsonschema` dev-dependency added (the
|
||||
structural-assertion sibling `direct_target_schema_gates_shape_per_
|
||||
substrate` is untouched and stays honest).
|
||||
- **D-4**: trimmed the preamble to the one-liner ("The graduation
|
||||
implementation … landed — see Added below."); the stale
|
||||
`tasks/tunnels/` sentence and the "only behavior-affecting change"
|
||||
claim are gone.
|
||||
- Verification: `cargo fmt --check`, `cargo clippy --all-targets -D
|
||||
warnings`, `cargo test` (86 default tests + 2 doc-tests) all green.
|
||||
@@ -0,0 +1,234 @@
|
||||
---
|
||||
id: tunnels/tunnel-listener
|
||||
name: TunnelListener — the listen-flow consumer session (ADR-008 §4)
|
||||
status: completed
|
||||
depends_on: [tunnels/bindfirst-params, tunnels/forwarded-receipt]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [consumer, listener, listen-flow, teardown, tunnel-listener]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement `TunnelListener` (consumer.md §The Listen Flow — ADR-008
|
||||
§4): the listen-flow sibling of `TunnelSession`. It OPENS a listen
|
||||
tunnel (`channels/tunnel/sub` — the establisher shape is the
|
||||
producer's choice, invisible here), reads the bind-first reply fields
|
||||
(`bound`/`listen`), presents them, and owns the listen channel's
|
||||
teardown (ADR-005's ownership posture extended to the listen flow).
|
||||
Forwarded connections arrive as ordinary adopted-side `TunnelSession`s
|
||||
on the opener's manager — `adopt` gains nothing.
|
||||
|
||||
### The type
|
||||
|
||||
```rust
|
||||
pub struct TunnelListener {
|
||||
channel_id: u32,
|
||||
manager: Option<ChannelManager>,
|
||||
bound: Option<SubstrateAddr>, // from the `bound` reply field
|
||||
token: Option<u32>, // from the `listen` reply field
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
impl TunnelListener {
|
||||
pub async fn open(client: &ChannelClient, params: TunnelParams)
|
||||
-> Result<Self, TunnelOpenError>;
|
||||
pub fn bound(&self) -> Option<&SubstrateAddr>;
|
||||
pub fn listen(&self) -> Option<u32>;
|
||||
pub async fn close(self) -> bool; // tear the listen channel down
|
||||
// Drop: sync-reap, best-effort — ADR-005's Drop posture
|
||||
}
|
||||
```
|
||||
|
||||
- **`open`** — `open_channel_with_reply(OP_TUNNEL_OPEN, params,
|
||||
TUNNEL_ALPN)` (the U-2 surface, landed alkcall 0.8.0): the full
|
||||
reply arrives, `listen_reply_fields` extracts
|
||||
`bound`/`listen`, `adopt`-free construction (the client already
|
||||
adopted the channel — the streams come back from
|
||||
`open_channel_with_reply`), and the listener holds the channel
|
||||
entry. The listen channel's own `BiStream` is held but IDLE — the
|
||||
listener session's drop/EOF is the teardown signal the far accept
|
||||
dispatcher watches (ADR-008 §1's contract); `TunnelListener` never
|
||||
pumps its own stream.
|
||||
- **The held BiStream (the one new thing):** unlike
|
||||
`TunnelSession` (which presents a data plane), a listen session's
|
||||
stream has NO data-plane role — its only job is to EOF when the
|
||||
session drops. Keep the stream (or its write half) alive in the
|
||||
struct so the drop → EOF ordering is structural (the mux sender
|
||||
drops → EOF sentinel). Do NOT expose the stream publicly —
|
||||
pumping on a listen channel is meaningless (the far side's
|
||||
dispatcher ignores it too).
|
||||
- **`close(self)`/`Drop`** — abort nothing (no pump), reap the
|
||||
adopted channel entry (`teardown_channel`). The far-side teardown
|
||||
cascades (EOF → accept-loop cancel → listener drop). Forwarded
|
||||
sessions are NOT owned by the listener (they arrive as separate
|
||||
adopted sessions the assembly correlates + owns) — document the
|
||||
split (listener teardown does not cascade to already-adopted
|
||||
forwarded sessions; the far listener dying stops NEW accepts).
|
||||
- **No `Clone`** — same posture as `TunnelSession` (aliasing would
|
||||
alias teardown). Compile-assert with a doc-test like the session's.
|
||||
- **`forwarded()`**: NOT a method on `TunnelListener` — forwarded
|
||||
sessions arrive on the opener's manager as adopted channels the
|
||||
assembly correlates via the `listen` token (consumer.md's posture;
|
||||
the correlation registry is assembly-side). The type hands the
|
||||
assembly the token (`listen()`); it does not own the receipt
|
||||
queue. (The task sketch in consumer.md showed `listener.forwarded()`
|
||||
— the honest shape is the assembly's map; document if a v1
|
||||
convenience accessor is still wanted — decide, don't silently
|
||||
drop.)
|
||||
|
||||
### The direct path (ADR-007's consumer ctor lives in `tunnels/direct-op`; this task adds nothing to `TunnelSession`)
|
||||
|
||||
Per ADR-008 §4: "`TunnelSession::adopt` gains nothing." Verify the
|
||||
claim while implementing: a forwarded channel adopted by the opener's
|
||||
assembly (`manager.adopt_channel` or `TunnelSession::adopt`) presents
|
||||
the same session/data-plane/teardown semantics — pin it with a test
|
||||
(adopt a forwarded channel, pump, teardown).
|
||||
|
||||
### Tests
|
||||
|
||||
- `open` against a bind-first producer (the harness from
|
||||
`tunnels/bindfirst-producer`): reply fields surface via
|
||||
`bound()`/`listen()`; teardown cascades (drop listener → far
|
||||
listener gone → subsequent forwarded opens fail).
|
||||
- `open` against a dial/listen producer: both fields `None` (the
|
||||
Option posture).
|
||||
- `close` → the listen channel reaps; `Drop` without close → reaped
|
||||
(the ADR-005 leak-free guarantee, listen-flavored).
|
||||
- Adopt-path pin: the forwarded session adopt + pump + teardown.
|
||||
- Scope interaction: an opener identity with `tunnel:open` but NOT
|
||||
`tunnel:forwarded` still opens the listen channel (that gate is on
|
||||
the producer side) — forwarded opens to it then fail FORBIDDEN
|
||||
(the per-accept isolation path from `tunnels/bindfirst-producer`).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `TunnelListener::open` uses `open_channel_with_reply`;
|
||||
`bound()`/`listen()` surface the reply fields (Option posture)
|
||||
- [x] Teardown: `close`/`Drop` reap the listen channel; the held
|
||||
BiStream's EOF is the structural teardown trigger; no
|
||||
`Clone`
|
||||
- [x] Forwarded sessions are assembly-adopted ordinary
|
||||
`TunnelSession`s (adopt gains nothing — pinned by test)
|
||||
- [x] Integration tests: bind-first e2e with the listener type,
|
||||
teardown cascade, Option posture, adopt-path pin
|
||||
- [x] Clippy/fmt clean; wasm32 check passes
|
||||
- [x] `cargo test` green
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
|
||||
(§4 the consumer API; Amendment 1 — the token the `listen()` view
|
||||
reads)
|
||||
- docs/architecture/decisions/005-consumer-session-owns-teardown.md
|
||||
(the ownership posture extended to the listen flow)
|
||||
- docs/architecture/consumer.md §The Listen Flow
|
||||
- tasks/tunnels/consumer-session.md (the TunnelSession shape this
|
||||
siblings — close/join/Drop posture, the no-Clone assert)
|
||||
- tasks/tunnels/bindfirst-params.md (the reply reader);
|
||||
tasks/tunnels/forwarded-receipt.md (the serving half the tests
|
||||
compose against)
|
||||
|
||||
## Notes
|
||||
|
||||
- **The held stream is the write half (`MpscSendStream`), not a
|
||||
`BiStream`.** The task sketch said "hold the stream (or its write
|
||||
half) alive"; the honest shape is the write half alone: the mux
|
||||
sender's drop is the structural EOF trigger (the mux pump writes
|
||||
the implicit-EOF chunk for the channel — REQ-CH-01's implicit path —
|
||||
which the far accept dispatcher's idle-stream watch observes as the
|
||||
zero-byte sentinel). Holding the read half too would add nothing
|
||||
(nothing ever reads it) and would keep the demux receiver alive.
|
||||
`close()` additionally calls `shutdown()` first (the explicit
|
||||
sentinel — the graceful path) before the reap; `Drop` relies on the
|
||||
implicit path.
|
||||
- **`forwarded()` decided: not provided.** Per the task's "decide,
|
||||
don't silently drop": the honest shape is the assembly's map — the
|
||||
listener hands the assembly the token via `listen()`; it does not
|
||||
own the receipt queue. A v1 convenience accessor would either own a
|
||||
queue (duplicating assembly state) or wrap one (an accessor over
|
||||
bookkeeping the type doesn't own). consumer.md's `listener
|
||||
.forwarded()` sketch line was already marked as the task-sketch
|
||||
shape; the docs (type docs + consumer.md) state the split.
|
||||
- **`open` is the ONLY construction path.** No `adopt` (the listener
|
||||
is an opener-flow type — the open reply carries the correlation
|
||||
material) and no `join` (there is no pump to await; the task's
|
||||
sketch has no `join` either — teardown is `close`/`Drop` only).
|
||||
- **Teardown ordering nuance (test-discovered, documented):** the
|
||||
far-side wrapper's teardown waits on its handler's R-02 await,
|
||||
which tracks every per-accept pump it spawned. The listener's EOF
|
||||
cancels the ACCEPT loop promptly, but the listen channel's own
|
||||
reap on the producer side lands when the last forwarded pump also
|
||||
winds down. The e2e drops the local BIND client's end before
|
||||
asserting the far reap (the forwarded pump's own EOF path).
|
||||
- **`close()` on a dial-producer listener (both fields `None`)** is
|
||||
ordinary: the Option posture does not affect teardown — the entry
|
||||
reaps regardless of what the establisher contributed.
|
||||
- **Harness addition (`wire_listener_bindfirst`, ListenerTopology):**
|
||||
the listen-flow topology — the OPENER dials the transport (connect
|
||||
side, `from_connection_with_serving`: it can both open the listen
|
||||
tunnel and serve the forwarded op post-hoc via its captured
|
||||
registry); the producer accepts (serving the bind-first openable;
|
||||
its forwarded-open caller rides the serving session's
|
||||
`CallConnection` + the producer-side manager — the design pin from
|
||||
`tunnels/bindfirst-producer`). The existing topologies could not
|
||||
serve this shape: `wire_bindfirst`'s consumer is a hook-captured
|
||||
`CallConnection` (no `ChannelClient`), and `wire_forward`'s
|
||||
producer-side install hook registers only dial establishers.
|
||||
- **The adopt-path pin lives in its own test** (`forwarded_channel
|
||||
_adopt_presents_ordinary_session_semantics`, the forwarded-serving
|
||||
standalone topology): the gate e2e's forwarded channel on the
|
||||
opener side is wrapper-managed (the serving wrapper allocated it —
|
||||
a second `adopt_channel` fails `ChannelExists`, the same property
|
||||
producer_bindfirst.rs pinned), so the pin drives the PRODUCER-side
|
||||
adoption — `TunnelSession::adopt` + pump + teardown on the
|
||||
opener-allocated ID — proving the session type gains nothing.
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented `TunnelListener` (src/consumer.rs, ~130 lines) per
|
||||
consumer.md §The Listen Flow + ADR-008 §4 as amended: `open` (rides
|
||||
`ChannelClient::open_channel_with_reply` — the full reply arrives and
|
||||
`listen_reply_fields` projects `bound`/`listen`; adopt-free
|
||||
construction), `bound()`/`listen()` (the Option posture), `close`
|
||||
(the graceful path: `shutdown` on the held write half — the explicit
|
||||
EOF sentinel — then the sync reap), and `Drop` (the held write half
|
||||
drops → the mux pump's implicit-EOF chunk — the far accept
|
||||
dispatcher's teardown signal — then the sync reap; never leaks).
|
||||
The held stream is the WRITE HALF (`MpscSendStream`) only, IDLE and
|
||||
private — a listen channel has no data-plane role. No `Clone`
|
||||
(compile_fail doc-test, the session's precedent). `forwarded()`
|
||||
decided: NOT provided — the receipt queue is assembly-side
|
||||
bookkeeping; the type hands the assembly the token (`listen()`) and
|
||||
the docs state the split (listener teardown does not cascade to
|
||||
already-adopted forwarded sessions; the far listener dying stops NEW
|
||||
accepts). `TunnelSession` gained nothing (the direct path lives in
|
||||
`tunnels/direct-op`, untouched).
|
||||
|
||||
Tests: `tests/consumer_listener.rs` — 5 integration tests: the
|
||||
bind-first gate (both reply fields surface; the forwarded flow
|
||||
round-trips through the REAL `register_tunnel_forwarded_openable`
|
||||
+ correlation map; the teardown cascade — drop listener → far
|
||||
listener gone → a follow-on accept is never consumed), the Option
|
||||
posture (a dial producer's open: both fields `None`; close reaps),
|
||||
close/Drop reaping (the ADR-005 leak-free guarantee,
|
||||
listen-flavored), the adopt-path pin (a forwarded channel adopted
|
||||
via `TunnelSession::adopt` presents the ordinary session semantics —
|
||||
adopt gains nothing), and the scope interaction (`tunnel:open`-only
|
||||
opener still opens the listen channel; forwarded opens to it fail
|
||||
FORBIDDEN before the assembly handler runs). Harness addition:
|
||||
`wire_listener_bindfirst` + `ListenerTopology` (the opener-dials
|
||||
shape — client + serving on the opener, bind-first serving +
|
||||
call-plane forwarded-open caller on the producer). One doc-fix drive-
|
||||
by in producer.rs (two broken intra-doc links, pre-existing).
|
||||
|
||||
Verified: `cargo test` green (132 default; 157 `--all-features`), doc
|
||||
tests pass (both `compile_fail` asserts), clippy `--all-targets -D
|
||||
warnings` clean (native + wasm32), fmt clean,
|
||||
`wasm32-unknown-unknown` check passes (the default crate stays
|
||||
wasm-clean), `cargo doc --no-deps` clean (0 warnings — the 2
|
||||
pre-existing broken links fixed). CHANGELOG updated (Unreleased →
|
||||
Added).
|
||||
@@ -0,0 +1,750 @@
|
||||
//! Integration tests for `TunnelListener` (the listen-flow consumer
|
||||
//! session — ADR-008 §4, the `tunnels/tunnel-listener` shape): the
|
||||
//! listen-flow sibling of `TunnelSession`. `open` rides
|
||||
//! `open_channel_with_reply` (the U-2 surface), the bind-first reply
|
||||
//! fields surface via `bound()`/`listen()` (the Option posture), the
|
||||
//! held-idle write half's drop/EOF is the structural teardown trigger
|
||||
//! the far accept dispatcher watches, and `close`/`Drop` reap the
|
||||
//! adopted listen channel (ADR-005's leak-free guarantee,
|
||||
//! listen-flavored).
|
||||
//!
|
||||
//! Covered (the task's test plan):
|
||||
//! - `open` against a bind-first producer: both reply fields surface;
|
||||
//! teardown cascades (drop listener → far listener gone →
|
||||
//! subsequent forwarded opens fail).
|
||||
//! - `open` against a dial producer: both fields `None` (the Option
|
||||
//! posture).
|
||||
//! - `close` reaps the listen channel; `Drop` without close reaps.
|
||||
//! - Adopt-path pin: a forwarded channel adopted via
|
||||
//! `TunnelSession::adopt` presents the ordinary session semantics
|
||||
//! (adopt gains nothing — pump + teardown on the adopted entry).
|
||||
//! - Scope interaction: an opener identity with `tunnel:open` but NOT
|
||||
//! `tunnel:forwarded` still opens the listen channel; forwarded
|
||||
//! opens to it then fail FORBIDDEN (per-accept isolation).
|
||||
//! - Graceful `close` (review 002 G-2): the explicit shutdown
|
||||
//! sentinel is observed at the FAR accept dispatcher — the idle-read
|
||||
//! fires, the loop cancels, the serving side reaps.
|
||||
//! - Write-happy peer (review 002 N-2's testable residue): a
|
||||
//! non-empty read on the idle listen stream tears the listener down
|
||||
//! (the fail-closed posture).
|
||||
|
||||
mod harness;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use alktunnels::params::{Substrate, SubstrateAddr, TunnelParams};
|
||||
use alktunnels::producer::{
|
||||
BindFirstAcceptLoop, BindFn, BoundListener, ResourceRegistry, TargetHandle,
|
||||
TunnelEstablishError,
|
||||
};
|
||||
use alktunnels::TunnelListener;
|
||||
|
||||
use harness::{wire_listener_bindfirst, ListenerTopology};
|
||||
|
||||
/// The correlation map: `listen` token → the TargetHandle the
|
||||
/// forwarded channel pumps against (the "local BIND client" halves).
|
||||
type CorrelationMap = Arc<tokio::sync::Mutex<std::collections::HashMap<u32, TargetHandle>>>;
|
||||
|
||||
fn listen_params(resource: &str) -> TunnelParams {
|
||||
TunnelParams {
|
||||
resource: resource.to_string(),
|
||||
substrate: Substrate::Tcp,
|
||||
}
|
||||
}
|
||||
|
||||
/// The test bind (the assembly's bind seam): produces a queue-backed
|
||||
/// "listener" + a fake bound address (a real `bind_tcp_first` lives in
|
||||
/// the `local` feature).
|
||||
fn test_bind() -> BindFn {
|
||||
Arc::new(|_substrate, _backing| {
|
||||
Box::pin(async {
|
||||
Ok(BoundListener {
|
||||
listener: Arc::new(4242u32) as Arc<dyn std::any::Any + Send + Sync>,
|
||||
bound: SubstrateAddr::HostPort {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 42113,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The test accept loop: pops from the test's VecDeque + Notify pair
|
||||
/// (one accepted pair per closure call — the dispatcher invokes the
|
||||
/// closure per accept).
|
||||
fn queue_loop(
|
||||
queue: Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
|
||||
notify: Arc<tokio::sync::Notify>,
|
||||
) -> BindFirstAcceptLoop {
|
||||
Arc::new(move |_listener| {
|
||||
let queue = Arc::clone(&queue);
|
||||
let notify = Arc::clone(¬ify);
|
||||
Box::pin(async move {
|
||||
let popped = {
|
||||
let notified = std::pin::pin!(notify.notified());
|
||||
let mut q = queue.lock().await;
|
||||
if let Some(pair) = q.pop_front() {
|
||||
Some(pair)
|
||||
} else {
|
||||
drop(q);
|
||||
notified.await;
|
||||
let mut q = queue.lock().await;
|
||||
q.pop_front()
|
||||
}
|
||||
};
|
||||
popped.ok_or(TunnelEstablishError::DialFailed(
|
||||
"listener closed".to_string(),
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The accept queue pair (VecDeque + Notify) the test accept loop
|
||||
/// pops from.
|
||||
type AcceptQueue = (
|
||||
Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
|
||||
Arc<tokio::sync::Notify>,
|
||||
);
|
||||
|
||||
fn fresh_queue() -> AcceptQueue {
|
||||
(
|
||||
Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new())),
|
||||
Arc::new(tokio::sync::Notify::new()),
|
||||
)
|
||||
}
|
||||
|
||||
/// An accepted "connection" for the test accept loop: a duplex pair
|
||||
/// with an echo task on the far end (the app server's socket stand-in).
|
||||
fn accepted_halves(peer_addr: (u32, u16)) -> (SubstrateAddr, TargetHandle) {
|
||||
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
|
||||
let (mut t_read, mut t_write) = tokio::io::split(target_side);
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match t_read.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => {
|
||||
if t_write.write_all(&buf[..n]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (c_read, c_write) = tokio::io::split(consumer_side);
|
||||
(
|
||||
SubstrateAddr::HostPort {
|
||||
host: peer_addr.0.to_string(),
|
||||
port: peer_addr.1,
|
||||
},
|
||||
TargetHandle {
|
||||
read: Box::new(c_read),
|
||||
write: Box::new(c_write),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Push an accepted pair into the test's accept queue.
|
||||
async fn push_accept(
|
||||
queue: &Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
|
||||
notify: &Arc<tokio::sync::Notify>,
|
||||
peer: (u32, u16),
|
||||
) {
|
||||
let (peer_addr, handle) = accepted_halves(peer);
|
||||
queue.lock().await.push_back((peer_addr, handle));
|
||||
notify.notify_waiters();
|
||||
}
|
||||
|
||||
/// Register the forwarded op on an opener-side serving registry with a
|
||||
/// correlation map (token → the local "BIND client" halves the
|
||||
/// assembly's pump bridges to) + an invocation witness (which tokens
|
||||
/// were correlated).
|
||||
fn install_forwarded_openable_on(
|
||||
registry: &Arc<alkcall::registry::registration::OperationRegistry>,
|
||||
manager: &alkcall::channels::manager::ChannelManager,
|
||||
) -> (CorrelationMap, Arc<tokio::sync::Mutex<Vec<u32>>>) {
|
||||
let correlations: CorrelationMap = Arc::new(tokio::sync::Mutex::new(Default::default()));
|
||||
let invoked: Arc<tokio::sync::Mutex<Vec<u32>>> = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let map = Arc::clone(&correlations);
|
||||
let seen = Arc::clone(&invoked);
|
||||
let handler: alktunnels::producer::ForwardedHandler = Arc::new(move |forwarded, bidi| {
|
||||
let map = Arc::clone(&map);
|
||||
let seen = Arc::clone(&seen);
|
||||
tokio::spawn(async move {
|
||||
seen.lock().await.push(forwarded.listen);
|
||||
let target = map.lock().await.remove(&forwarded.listen);
|
||||
if let Some(target) = target {
|
||||
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
|
||||
}
|
||||
})
|
||||
});
|
||||
let core = alkcall::channels::operations::ChannelCore::new(
|
||||
manager.clone(),
|
||||
alkcall::channels::policy::default_policy(),
|
||||
);
|
||||
alktunnels::producer::register_tunnel_forwarded_openable(
|
||||
&core,
|
||||
registry,
|
||||
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
|
||||
handler,
|
||||
)
|
||||
.expect("register forwarded openable on the opener");
|
||||
(correlations, invoked)
|
||||
}
|
||||
|
||||
/// The "local BIND client" pair (the opener-side pump target): the
|
||||
/// map's halves are the assembly pump's target, the far end is the
|
||||
/// test's client end (driven directly).
|
||||
fn bind_client_pair() -> (
|
||||
tokio::io::ReadHalf<tokio::io::DuplexStream>,
|
||||
tokio::io::WriteHalf<tokio::io::DuplexStream>,
|
||||
TargetHandle,
|
||||
) {
|
||||
let (a, b) = tokio::io::duplex(64 * 1024);
|
||||
let (a_read, a_write) = tokio::io::split(a);
|
||||
let (b_read, b_write) = tokio::io::split(b);
|
||||
(
|
||||
b_read,
|
||||
b_write,
|
||||
TargetHandle {
|
||||
read: Box::new(a_read),
|
||||
write: Box::new(a_write),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn opener_only_channel_0(topo: &ListenerTopology) -> bool {
|
||||
topo.opener
|
||||
.manager()
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0)
|
||||
}
|
||||
|
||||
/// THE gate: `open` against the bind-first producer — both reply
|
||||
/// fields surface via `bound()`/`listen()`, the forwarded flow runs
|
||||
/// through a REAL `TunnelSession::adopt` of the producer-allocated
|
||||
/// forwarded channel (adopt gains nothing — the session type is
|
||||
/// ordinary), and dropping the listener tears the far listener down
|
||||
/// (subsequent forwarded opens fail).
|
||||
#[tokio::test]
|
||||
async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() {
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let (queue, notify) = fresh_queue();
|
||||
let topo = wire_listener_bindfirst(
|
||||
registry,
|
||||
test_bind(),
|
||||
queue_loop(queue.clone(), notify.clone()),
|
||||
harness::forwarded_opener_identity(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// The listener opens the listen tunnel.
|
||||
let listener = TunnelListener::open(&topo.opener, listen_params("bind"))
|
||||
.await
|
||||
.expect("listen open");
|
||||
let channel_id = listener.channel_id;
|
||||
assert!(
|
||||
topo.producer_manager.has_channel(channel_id),
|
||||
"the listen channel is live on the producer side"
|
||||
);
|
||||
// Reply fields (the Option posture, bind-first present case).
|
||||
assert_eq!(
|
||||
listener.bound(),
|
||||
Some(&SubstrateAddr::HostPort {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 42113,
|
||||
}),
|
||||
"the `bound` field is the OS-chosen (test-chosen) address"
|
||||
);
|
||||
let token = listener.listen().expect("the correlation token");
|
||||
assert!(token > 0);
|
||||
|
||||
// The opener-side serving half + the correlation map (keyed on the
|
||||
// token from THIS listener's reply).
|
||||
let (correlations, _invoked) =
|
||||
install_forwarded_openable_on(&topo.opener_registry, topo.opener.manager());
|
||||
let (mut b_read, mut b_write, local) = bind_client_pair();
|
||||
correlations.lock().await.insert(token, local);
|
||||
|
||||
// The producer's accept loop accepts (the app server dialed).
|
||||
push_accept(&queue, ¬ify, (198, 55231)).await;
|
||||
|
||||
// The forwarded channel arrives as a PRODUCER-INITIATED open the
|
||||
// opener serves: the opener-allocated ID appears on the opener's
|
||||
// manager (wrapper-managed — the serving side allocates, ADR-047
|
||||
// §5; the PRODUCER adopts it inside the harness's forwarded-open
|
||||
// caller and pumps the accepted halves against it).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let forwarded_ids: Vec<u32> = topo
|
||||
.opener
|
||||
.manager()
|
||||
.channel_ids()
|
||||
.into_iter()
|
||||
.filter(|&id| id != 0 && id != channel_id)
|
||||
.collect();
|
||||
assert_eq!(forwarded_ids.len(), 1, "one forwarded channel arrived");
|
||||
|
||||
// THE round-trip (the adopt-path pin's data half): bytes the test
|
||||
// writes on the local BIND client's end cross the assembly pump →
|
||||
// the forwarded channel → the producer-side adopted pump → the
|
||||
// accepted handle's echo → and back. The forwarded session is an
|
||||
// ordinary channel — `adopt` gains nothing (the standalone adopt
|
||||
// pin below drives the same session type directly).
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
b_write.write_all(b"bind-ping").await.expect("write");
|
||||
b_write.flush().await.expect("flush");
|
||||
let mut buf = vec![0u8; 9];
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
b_read.read_exact(&mut buf),
|
||||
)
|
||||
.await
|
||||
.expect("round trip")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, b"bind-ping");
|
||||
|
||||
// Teardown cascade: drop the listener → the held write half EOFs
|
||||
// the listen channel → the far accept loop cancels → the far
|
||||
// listener is gone (a follow-on accept is never consumed). The
|
||||
// client end drops first (the forwarded session's own teardown —
|
||||
// the pump winds down; the handler's R-02 await unblocks only
|
||||
// when every per-accept pump finished).
|
||||
drop(b_read);
|
||||
drop(b_write);
|
||||
drop(listener);
|
||||
for _ in 0..30 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
if !topo.producer_manager.has_channel(channel_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!topo.producer_manager.has_channel(channel_id),
|
||||
"the listen channel reaped after the listener dropped"
|
||||
);
|
||||
push_accept(&queue, ¬ify, (198, 55232)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
assert_eq!(
|
||||
queue.lock().await.len(),
|
||||
1,
|
||||
"the far accept loop is gone: the pushed accept was never consumed"
|
||||
);
|
||||
}
|
||||
|
||||
/// The adopt-path pin (ADR-008 §4: "`TunnelSession::adopt` gains
|
||||
/// nothing — forwarded channels are ordinary adopted channels"): in
|
||||
/// the forwarded-serving standalone shape (the test plays the
|
||||
/// producer), a forwarded channel adopted via `TunnelSession::adopt`
|
||||
/// presents the ordinary session semantics — substrate-shaped data
|
||||
/// plane, pump, teardown — identical to any other adopted session.
|
||||
#[tokio::test]
|
||||
async fn forwarded_channel_adopt_presents_ordinary_session_semantics() {
|
||||
// The forwarded-serving topology: the producer leg (connect side,
|
||||
// `topo.producer`) holds the client + manager; the opener leg
|
||||
// (accept side) serves the forwarded op via the REAL
|
||||
// registration.
|
||||
let topo = harness::wire_with(
|
||||
alktunnels::producer::ResourceRegistry::new(),
|
||||
harness::RegistrationMode::Dial(harness::failing_dial("base op unused")),
|
||||
Some(harness::forwarded_opener_identity()),
|
||||
None,
|
||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||
)
|
||||
.await;
|
||||
let (correlations, _invoked) =
|
||||
install_forwarded_openable_on(&topo.consumer_registry, &topo.consumer_manager);
|
||||
|
||||
// The opener's assembly correlated the local halves for the token
|
||||
// BEFORE the forwarded open arrives (the waiting-session posture).
|
||||
const TOKEN: u32 = 918273;
|
||||
let (mut b_read, b_write, local) = bind_client_pair();
|
||||
correlations.lock().await.insert(TOKEN, local);
|
||||
|
||||
// The producer calls the forwarded op (valid params + scope) and
|
||||
// adopts the opener-allocated ID — via `TunnelSession::adopt` (the
|
||||
// session type's adopt path; `adopt gains nothing`).
|
||||
let out = topo
|
||||
.producer
|
||||
.call_open_op(
|
||||
alktunnels::OP_TUNNEL_FORWARDED,
|
||||
serde_json::json!({
|
||||
"listen": TOKEN,
|
||||
"substrate": "tcp",
|
||||
"peer": {"host": "198", "port": 55231},
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.result
|
||||
.expect("forwarded open");
|
||||
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
||||
|
||||
let mut forwarded_session = alktunnels::TunnelSession::adopt(
|
||||
topo.producer.manager(),
|
||||
channel_id,
|
||||
Substrate::Tcp,
|
||||
alktunnels::TUNNEL_ALPN,
|
||||
)
|
||||
.await
|
||||
.expect("adopt the forwarded channel (ordinary session semantics)");
|
||||
|
||||
// The adopted session pumps like any session: bytes cross the
|
||||
// adopted channel → the assembly pump → the local BIND client
|
||||
// halves → and back.
|
||||
let (_f_read, f_write) = forwarded_session
|
||||
.stream_halves()
|
||||
.expect("stream halves on the adopted forwarded session");
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
f_write.write_all(b"adopt-ping").await.expect("write");
|
||||
f_write.flush().await.expect("flush");
|
||||
let mut buf = vec![0u8; 10];
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
b_read.read_exact(&mut buf),
|
||||
)
|
||||
.await
|
||||
.expect("round trip")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, b"adopt-ping");
|
||||
|
||||
// Teardown: the adopted session reaps its OWN entry (per-session,
|
||||
// ADR-005) — independent of any listener. The far end (the local
|
||||
// BIND client end) drops first: the assembly pump's `pump_bidi`
|
||||
// (R-02, the wrapper's handler task) unwinds when both pumps
|
||||
// finish, and only THEN does the wrapper's handler-exit teardown
|
||||
// reap the serving side.
|
||||
drop(b_read);
|
||||
drop(b_write);
|
||||
let reaped = forwarded_session.close().await;
|
||||
assert!(reaped, "close reaped the adopted forwarded entry");
|
||||
for _ in 0..30 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
if !topo.consumer_manager.has_channel(channel_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!topo.consumer_manager.has_channel(channel_id),
|
||||
"the forwarded channel reaped on the opener side (wrapper teardown)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The Option posture: `open` against a plain DIAL producer — both
|
||||
/// reply fields `None`, the session is ordinary. (`wire_forward`'s
|
||||
/// producer (accept side) serves the base openable with a DIAL
|
||||
/// establisher — a dial establisher never binds, so the reply carries
|
||||
/// neither field; the listener session is otherwise ordinary.)
|
||||
#[tokio::test]
|
||||
async fn dial_producer_open_both_fields_none() {
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("echo", Substrate::Tcp, "in-process")
|
||||
.await;
|
||||
let topo = harness::wire_forward(registry, harness::echo_dial()).await;
|
||||
|
||||
let listener = TunnelListener::open(&topo.consumer, listen_params("echo"))
|
||||
.await
|
||||
.expect("dial open");
|
||||
assert_eq!(listener.bound(), None, "a dial establisher binds nothing");
|
||||
assert_eq!(listener.listen(), None, "no token minted");
|
||||
|
||||
// The listener still owns the channel entry; close reaps it.
|
||||
let channel_id = listener.channel_id;
|
||||
assert!(topo.producer_manager.has_channel(channel_id));
|
||||
let reaped = listener.close().await;
|
||||
assert!(reaped, "close reaped the adopted entry");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert!(!topo.producer_manager.has_channel(channel_id));
|
||||
assert!(
|
||||
topo.consumer
|
||||
.manager()
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0)
|
||||
&& topo
|
||||
.producer_manager
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0),
|
||||
"no leaked entries after close"
|
||||
);
|
||||
}
|
||||
|
||||
/// `close` reaps the listen channel; `Drop` without close reaps (the
|
||||
/// ADR-005 leak-free guarantee, listen-flavored).
|
||||
#[tokio::test]
|
||||
async fn close_and_drop_reap_the_listen_channel() {
|
||||
// close(): the graceful path (shutdown → teardown).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let (queue, notify) = fresh_queue();
|
||||
let topo = wire_listener_bindfirst(
|
||||
registry,
|
||||
test_bind(),
|
||||
queue_loop(queue, notify),
|
||||
harness::forwarded_opener_identity(),
|
||||
)
|
||||
.await;
|
||||
let listener = TunnelListener::open(&topo.opener, listen_params("bind"))
|
||||
.await
|
||||
.expect("listen open");
|
||||
let id1 = listener.channel_id;
|
||||
assert!(listener.close().await, "close reaped the entry");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert!(!topo.producer_manager.has_channel(id1));
|
||||
|
||||
// Drop(): the best-effort path — no leak.
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let (queue, notify) = fresh_queue();
|
||||
let topo = wire_listener_bindfirst(
|
||||
registry,
|
||||
test_bind(),
|
||||
queue_loop(queue, notify),
|
||||
harness::forwarded_opener_identity(),
|
||||
)
|
||||
.await;
|
||||
let listener = TunnelListener::open(&topo.opener, listen_params("bind"))
|
||||
.await
|
||||
.expect("listen open");
|
||||
let id2 = listener.channel_id;
|
||||
drop(listener);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert!(
|
||||
!topo.producer_manager.has_channel(id2),
|
||||
"Drop reaped the adopted entry (no leak)"
|
||||
);
|
||||
assert!(opener_only_channel_0(&topo), "no leaked entries after Drop");
|
||||
}
|
||||
|
||||
/// Scope interaction: an opener identity with `tunnel:open` but NOT
|
||||
/// `tunnel:forwarded` still opens the listen channel (that gate is on
|
||||
/// the producer side, `tunnel:open`-shaped); forwarded opens to it
|
||||
/// then fail FORBIDDEN (the per-accept isolation path).
|
||||
#[tokio::test]
|
||||
async fn open_only_opener_still_opens_listen_but_forwarded_fails() {
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let (queue, notify) = fresh_queue();
|
||||
let topo = wire_listener_bindfirst(
|
||||
registry,
|
||||
test_bind(),
|
||||
queue_loop(queue.clone(), notify.clone()),
|
||||
harness::forwarded_scopeless_identity(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// The listener opens fine (the producer-side gate is `tunnel:open`).
|
||||
let listener = TunnelListener::open(&topo.opener, listen_params("bind"))
|
||||
.await
|
||||
.expect("listen open succeeds with tunnel:open alone");
|
||||
assert!(listener.bound().is_some());
|
||||
let token = listener.listen().expect("token");
|
||||
|
||||
// The opener serves the forwarded op with a correlation map (the
|
||||
// map holds the token, but the ACL rejects the open before the
|
||||
// handler runs).
|
||||
let (correlations, invoked) =
|
||||
install_forwarded_openable_on(&topo.opener_registry, topo.opener.manager());
|
||||
correlations.lock().await.insert(token, {
|
||||
let (_l_read, _l_write, local) = bind_client_pair();
|
||||
local
|
||||
});
|
||||
|
||||
// The accept: the producer's dispatcher opens forwarded → the
|
||||
// scope-less opener's ACL rejects FORBIDDEN → the accepted
|
||||
// connection drops, the loop continues (per-accept isolation).
|
||||
push_accept(&queue, ¬ify, (198, 55233)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
assert_eq!(
|
||||
queue.lock().await.len(),
|
||||
0,
|
||||
"the accept was consumed (the loop is alive)"
|
||||
);
|
||||
let listen_id = listener.channel_id;
|
||||
assert!(
|
||||
topo.opener
|
||||
.manager()
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0 || id == listen_id),
|
||||
"a FORBIDDEN forwarded open leaves no channel on the opener side"
|
||||
);
|
||||
assert!(
|
||||
invoked.lock().await.is_empty(),
|
||||
"the assembly handler never ran (the ACL rejected before it)"
|
||||
);
|
||||
drop(listener);
|
||||
}
|
||||
|
||||
/// The graceful-teardown half of ADR-008 §1's contract (review 002
|
||||
/// G-2): `TunnelListener::close`'s explicit `shutdown` (the EOF
|
||||
/// sentinel) is observed at the FAR accept dispatcher — the idle-read
|
||||
/// fires, the accept loop cancels, the pushed accept is never
|
||||
/// consumed, and the serving-side listen channel reaps. The Drop path
|
||||
/// (`bindfirst_open_surfaces_reply_fields_and_teardown_cascades`) and
|
||||
/// the reap level (`close_and_drop_reap_the_listen_channel`) are
|
||||
/// pinned elsewhere; this test observes the sentinel's far-side
|
||||
/// effect for `close()` specifically.
|
||||
#[tokio::test]
|
||||
async fn graceful_close_eof_sentinel_observed_at_far_accept_dispatcher() {
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let (queue, notify) = fresh_queue();
|
||||
let topo = wire_listener_bindfirst(
|
||||
registry,
|
||||
test_bind(),
|
||||
queue_loop(queue.clone(), notify.clone()),
|
||||
harness::forwarded_opener_identity(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let listener = TunnelListener::open(&topo.opener, listen_params("bind"))
|
||||
.await
|
||||
.expect("listen open");
|
||||
let channel_id = listener.channel_id;
|
||||
assert!(topo.producer_manager.has_channel(channel_id));
|
||||
let token = listener.listen().expect("the correlation token");
|
||||
|
||||
// A pushed accept consumed BEFORE close would prove the loop is
|
||||
// alive at that point; it also leaves the queue-len assertion
|
||||
// below unambiguous (one unconsumed accept = the loop is gone).
|
||||
let (correlations, _invoked) =
|
||||
install_forwarded_openable_on(&topo.opener_registry, topo.opener.manager());
|
||||
correlations.lock().await.insert(token, {
|
||||
let (_l_read, _l_write, local) = bind_client_pair();
|
||||
local
|
||||
});
|
||||
push_accept(&queue, ¬ify, (198, 55234)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
assert_eq!(
|
||||
queue.lock().await.len(),
|
||||
0,
|
||||
"the accept loop is alive before close (the accept was consumed)"
|
||||
);
|
||||
|
||||
// THE graceful path: `close` runs the explicit shutdown sentinel,
|
||||
// then reaps. The far dispatcher's idle read observes the
|
||||
// sentinel; the loop cancels.
|
||||
assert!(listener.close().await, "close reaped the entry");
|
||||
|
||||
// The serving-side listen channel reaps (the handler-exit
|
||||
// teardown, the same posture the Drop test pins).
|
||||
let mut reaped = false;
|
||||
for _ in 0..30 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
if !topo.producer_manager.has_channel(channel_id) {
|
||||
reaped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(reaped, "the listen channel reaped after the graceful close");
|
||||
|
||||
// The far accept loop is gone: a follow-on accept is never
|
||||
// consumed (the queue-len assertion the Drop-shaped tests use).
|
||||
push_accept(&queue, ¬ify, (198, 55235)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
assert_eq!(
|
||||
queue.lock().await.len(),
|
||||
1,
|
||||
"the far accept loop is gone after the graceful close: the pushed accept was never consumed"
|
||||
);
|
||||
}
|
||||
|
||||
/// The write-happy-peer teardown posture (review 002 N-2's testable
|
||||
/// residue): a non-empty read on the idle listen stream tears the
|
||||
/// listener down — the fail-closed arm (`Ok(_)` in the idle-stream EOF
|
||||
/// watch, src/producer.rs). The channel is not a data plane; ANY
|
||||
/// non-empty read is a teardown trigger, not just the zero-byte
|
||||
/// sentinels. The typed `TunnelListener` never writes, so the test
|
||||
/// plays the misbehaving opener with the raw client surface (open +
|
||||
/// hold both halves; write one byte into the idle stream).
|
||||
#[tokio::test]
|
||||
async fn write_happy_peer_on_idle_listen_channel_tears_down_far_listener() {
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let (queue, notify) = fresh_queue();
|
||||
let topo = wire_listener_bindfirst(
|
||||
registry,
|
||||
test_bind(),
|
||||
queue_loop(queue.clone(), notify.clone()),
|
||||
harness::forwarded_opener_identity(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// The misbehaving opener: the raw client surface (the typed
|
||||
// `TunnelListener` never exposes the write half — it holds it
|
||||
// idle by contract). Hold the write half ALIVE: the idle read
|
||||
// must fire from the written byte, not from a dropped session.
|
||||
let input = serde_json::to_value(listen_params("bind")).expect("params serialize");
|
||||
let (channel_id, _reply, mut write, _recv) = topo
|
||||
.opener
|
||||
.open_channel_with_reply(
|
||||
alktunnels::params::OP_TUNNEL_OPEN,
|
||||
input,
|
||||
alktunnels::params::TUNNEL_ALPN,
|
||||
)
|
||||
.await
|
||||
.expect("listen open via the raw client");
|
||||
assert!(topo.producer_manager.has_channel(channel_id));
|
||||
|
||||
// Prove the accept loop is alive FIRST (a consumed accept), then
|
||||
// write a byte into the idle channel — the dispatcher's idle read
|
||||
// observes the non-empty read and tears the listener down
|
||||
// (fail-closed).
|
||||
push_accept(&queue, ¬ify, (198, 55236)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
assert_eq!(
|
||||
queue.lock().await.len(),
|
||||
0,
|
||||
"the accept loop is alive before the misbehaved write (no forwarded-op serving stub is installed; the open fails, per-accept isolation)"
|
||||
);
|
||||
|
||||
// The misbehavior: one byte on the idle stream.
|
||||
use tokio::io::AsyncWriteExt;
|
||||
write.write_all(b"x").await.expect("write a byte");
|
||||
write.flush().await.expect("flush");
|
||||
|
||||
// The fail-closed posture: the accept loop cancels and the
|
||||
// listener tears down with the plan (the serving-side channel
|
||||
// reaps via the handler-exit teardown).
|
||||
let mut reaped = false;
|
||||
for _ in 0..30 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
if !topo.producer_manager.has_channel(channel_id) {
|
||||
reaped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
reaped,
|
||||
"the listen channel reaped after the write-happy peer misbehaved"
|
||||
);
|
||||
|
||||
// The far accept loop is gone: a follow-on accept is never
|
||||
// consumed.
|
||||
push_accept(&queue, ¬ify, (198, 55237)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
assert_eq!(
|
||||
queue.lock().await.len(),
|
||||
1,
|
||||
"the far accept loop is gone after the write-happy peer (the pushed accept was never consumed)"
|
||||
);
|
||||
|
||||
// The misbehaving opener's own halves drop (test hygiene).
|
||||
drop(write);
|
||||
}
|
||||
+625
-8
@@ -22,6 +22,7 @@
|
||||
//! dialing). CF-006 (the per-call opener identity on the establisher)
|
||||
//! is probed via the `identity_witness` the establisher records.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::channels::adapter::{ChannelsAdapter, InstallChannelZero};
|
||||
@@ -34,8 +35,12 @@ use alkcall::protocol::connection::{split_single_stream, CallConnection};
|
||||
use alkcall::protocol::dispatch::Dispatcher;
|
||||
use alkcall::registry::registration::OperationRegistry;
|
||||
|
||||
use alktunnels::params::{Substrate, TUNNEL_OPEN_SCOPE};
|
||||
use alktunnels::producer::{register_tunnel_openable, ResourceRegistry};
|
||||
use alktunnels::params::{
|
||||
Substrate, TUNNEL_DIRECT_SCOPE, TUNNEL_FORWARDED_SCOPE, TUNNEL_OPEN_SCOPE,
|
||||
};
|
||||
use alktunnels::producer::{
|
||||
register_tunnel_direct_openable, register_tunnel_openable, ResourceRegistry,
|
||||
};
|
||||
|
||||
/// The consumer identity the transport carries (the mTLS/QUIC
|
||||
/// analogue — key-based identity resolved out-of-band, attached to
|
||||
@@ -48,6 +53,29 @@ pub fn consumer_identity() -> Identity {
|
||||
}
|
||||
}
|
||||
|
||||
/// The direct-egress identity: `tunnel:direct` ONLY (no
|
||||
/// `tunnel:open`) — the scope-separation probe's identity.
|
||||
pub fn direct_identity() -> Identity {
|
||||
Identity {
|
||||
id: "consumer-direct".to_string(),
|
||||
scopes: vec![TUNNEL_DIRECT_SCOPE.to_string()],
|
||||
resources: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Both scopes: `tunnel:open` AND `tunnel:direct` (a deployment
|
||||
/// granting both grants both explicitly — ADR-006 Amendment 1).
|
||||
pub fn both_scopes_identity() -> Identity {
|
||||
Identity {
|
||||
id: "consumer-both".to_string(),
|
||||
scopes: vec![
|
||||
TUNNEL_OPEN_SCOPE.to_string(),
|
||||
TUNNEL_DIRECT_SCOPE.to_string(),
|
||||
],
|
||||
resources: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The scoped token (the hub-forwarding path) — resolves to
|
||||
/// [`consumer_identity`] via [`TestIdProvider`].
|
||||
pub const TEST_AUTH_TOKEN: &str = "test-token-with-tunnel-open-scope";
|
||||
@@ -82,6 +110,9 @@ pub struct Topology {
|
||||
pub consumer_call: Arc<CallConnection>,
|
||||
/// The producer's serving registry (probes).
|
||||
pub producer_registry: Arc<OperationRegistry>,
|
||||
/// The consumer's serving registry (the opener-side op stubs —
|
||||
/// e.g. the forwarded-op serving stub — pre-register here).
|
||||
pub consumer_registry: Arc<OperationRegistry>,
|
||||
/// What the establisher's per-call auth carried as the opener
|
||||
/// identity id (the CF-006 probe — `None` until an open runs).
|
||||
pub identity_witness: Arc<tokio::sync::Mutex<Option<String>>>,
|
||||
@@ -105,6 +136,22 @@ pub enum RegistrationMode {
|
||||
/// The no-witness dial establisher (`tunnel_establisher` — the
|
||||
/// public default shape, no CF-006 probe seam).
|
||||
DialNoWitness(alktunnels::producer::DialFn),
|
||||
/// The direct op (`channels/tunnel/direct`, ADR-007): the direct
|
||||
/// spec + the direct establisher (no registry lookup) over the
|
||||
/// SAME injected dial, with the CF-006 witness seam. The witness
|
||||
/// rides the topology's `identity_witness` (the direct op's
|
||||
/// witness and the base op's never mix in one test).
|
||||
Direct(alktunnels::producer::DialFn),
|
||||
/// The bind-first establisher (shape 3, ADR-008): the bind-first
|
||||
/// establisher + the accept dispatcher over the injected bind +
|
||||
/// accept loop + forwarded-open caller. The forwarded-op caller
|
||||
/// rides the producer-side call plane (`producer_client`'s call
|
||||
/// connection — captured in the topology) and adopts on the
|
||||
/// producer-side manager.
|
||||
BindFirst(
|
||||
alktunnels::producer::BindFn,
|
||||
alktunnels::producer::BindFirstAcceptLoop,
|
||||
),
|
||||
}
|
||||
|
||||
pub async fn wire_with(
|
||||
@@ -120,28 +167,47 @@ pub async fn wire_with(
|
||||
let (consumer_manager_tx, mut consumer_manager_rx) =
|
||||
tokio::sync::mpsc::channel::<ChannelManager>(1);
|
||||
|
||||
// The consumer's serving registry: empty by default; a test that
|
||||
// installs opener-side ops (the forwarded-op serving stub — the
|
||||
// ADR-008 §3 ingress shape) pre-registers them here so the
|
||||
// dispatch loop serves them from the first inbound call.
|
||||
let consumer_serving_registry = Arc::new(OperationRegistry::new());
|
||||
let registry_for_hook = Arc::clone(&consumer_serving_registry);
|
||||
let identity_for_hook = transport_identity.clone();
|
||||
|
||||
// --- consumer (accept side): ChannelsAdapter + capturing hook ------
|
||||
let install_hook: InstallChannelZero = Arc::new(move |manager, channel0_conn, _auth| {
|
||||
let call_tx = consumer_call_tx.clone();
|
||||
let manager_tx = consumer_manager_tx.clone();
|
||||
let serving_registry = Arc::clone(®istry_for_hook);
|
||||
let peer_identity = identity_for_hook.clone();
|
||||
tokio::spawn(async move {
|
||||
let channel0_bidi = match channel0_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
// The accept-side transport-identity posture (the same
|
||||
// propagation `wire_forward` models): the transport
|
||||
// authenticated the dialer; attach the resolved identity
|
||||
// to channel 0 so the serving dispatch sees the caller
|
||||
// (CF-005 (b)'s accept-side analogue).
|
||||
if let Some(identity) = peer_identity {
|
||||
let _ = channel0_conn.set_identity(identity);
|
||||
}
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
let _ = call_tx.send(Arc::clone(&call_connection)).await;
|
||||
let _ = manager_tx.send(manager).await;
|
||||
// The consumer serves no ops — an empty registry. The
|
||||
// dispatcher keeps the call loop alive (its read loop
|
||||
// resolves the producer's responses to the consumer's
|
||||
// pending entries).
|
||||
// The consumer serves the ops the test pre-registered on
|
||||
// its serving registry (empty in the default topology —
|
||||
// the dispatcher keeps the call loop alive either way; its
|
||||
// read loop resolves the producer's responses to the
|
||||
// consumer's pending entries).
|
||||
let dp = Dispatcher::new(
|
||||
Arc::new(OperationRegistry::new()),
|
||||
serving_registry,
|
||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||
);
|
||||
dp.serve_single_stream(call_connection, reader, writer)
|
||||
@@ -200,6 +266,7 @@ pub async fn wire_with(
|
||||
producer_client.manager().clone(),
|
||||
alkcall::channels::policy::default_policy(),
|
||||
);
|
||||
let producer_client = Arc::new(producer_client);
|
||||
match mode {
|
||||
RegistrationMode::Dial(dial) => {
|
||||
register_tunnel_openable(
|
||||
@@ -212,6 +279,16 @@ pub async fn wire_with(
|
||||
)
|
||||
.expect("register tunnel openable");
|
||||
}
|
||||
RegistrationMode::Direct(dial) => {
|
||||
register_tunnel_direct_openable(
|
||||
&core,
|
||||
&producer_op_registry,
|
||||
AuthContext::anonymous(b"alk/tunnel"),
|
||||
dial,
|
||||
Some(Arc::clone(&identity_witness)),
|
||||
)
|
||||
.expect("register tunnel direct openable");
|
||||
}
|
||||
RegistrationMode::Listen(accept) => {
|
||||
alktunnels::producer::register_tunnel_listen_openable(
|
||||
&core,
|
||||
@@ -222,6 +299,75 @@ pub async fn wire_with(
|
||||
)
|
||||
.expect("register tunnel listen openable");
|
||||
}
|
||||
RegistrationMode::BindFirst(bind, accept_loop) => {
|
||||
// The bind-first shape (ADR-008). The forwarded-op caller
|
||||
// closes over the producer-side call plane + manager (the
|
||||
// design pin: the per-channel `Connection` the handler
|
||||
// receives is stream-less and carries no call surface, so
|
||||
// the registration takes the call-plane handle — here the
|
||||
// serving session's `CallConnection`, which the client
|
||||
// holds; in production the assembly layer has the same
|
||||
// handle because it constructed the serving session). The
|
||||
// harness reaches it via `producer_client.call_open_op` +
|
||||
// `adopt_channel` — the reverse POC's
|
||||
// `open_reverse_channel` shape, inline (the reply's extra
|
||||
// fields are not needed for the forwarded op; the channel
|
||||
// ID extraction + adopt is the whole dance).
|
||||
let manager = producer_client.manager().clone();
|
||||
let client_for_opener = Arc::clone(&producer_client);
|
||||
let open_forwarded: alktunnels::producer::ForwardedOpener = Arc::new(
|
||||
move |forwarded: alktunnels::params::TunnelForwardedParams| {
|
||||
let client = Arc::clone(&client_for_opener);
|
||||
let manager = manager.clone();
|
||||
Box::pin(async move {
|
||||
let input = serde_json::to_value(&forwarded).map_err(|e| {
|
||||
alktunnels::producer::TunnelEstablishError::HandlerError(format!(
|
||||
"forwarded params serialize: {e}"
|
||||
))
|
||||
})?;
|
||||
let response = client
|
||||
.call_open_op(alktunnels::params::OP_TUNNEL_FORWARDED, input)
|
||||
.await;
|
||||
let out = response.result.map_err(|e| {
|
||||
alktunnels::producer::TunnelEstablishError::HandlerError(format!(
|
||||
"forwarded open: {}",
|
||||
e.message
|
||||
))
|
||||
})?;
|
||||
let channel_id =
|
||||
out.get("channel_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| {
|
||||
alktunnels::producer::TunnelEstablishError::HandlerError(
|
||||
"forwarded open reply missing channel_id".to_string(),
|
||||
)
|
||||
})? as u32;
|
||||
let (send, recv) = manager
|
||||
.adopt_channel(channel_id, alktunnels::params::TUNNEL_ALPN, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
alktunnels::producer::TunnelEstablishError::HandlerError(format!(
|
||||
"forwarded adopt: {e}"
|
||||
))
|
||||
})?;
|
||||
Ok((send, recv))
|
||||
})
|
||||
},
|
||||
);
|
||||
alktunnels::producer::register_tunnel_bindfirst_openable(
|
||||
&core,
|
||||
®istry,
|
||||
&producer_op_registry,
|
||||
AuthContext::anonymous(b"alk/tunnel"),
|
||||
alktunnels::producer::BindFirstInjections {
|
||||
bind,
|
||||
accept_loop,
|
||||
open_forwarded,
|
||||
},
|
||||
Some(Arc::clone(&identity_witness)),
|
||||
)
|
||||
.expect("register tunnel bindfirst openable");
|
||||
}
|
||||
RegistrationMode::DialNoWitness(dial) => {
|
||||
// The public no-witness wrapper (tunnel_establisher) —
|
||||
// registration + establisher body identical to the
|
||||
@@ -279,7 +425,6 @@ pub async fn wire_with(
|
||||
.expect("register hanging establisher");
|
||||
}
|
||||
}
|
||||
let producer_client = Arc::new(producer_client);
|
||||
|
||||
// --- consumer pieces (captured from the hook) -------------------------
|
||||
let consumer_call = consumer_call_rx.recv().await.expect("consumer call conn");
|
||||
@@ -290,6 +435,7 @@ pub async fn wire_with(
|
||||
consumer_manager,
|
||||
consumer_call,
|
||||
producer_registry: producer_op_registry,
|
||||
consumer_registry: consumer_serving_registry,
|
||||
identity_witness,
|
||||
}
|
||||
}
|
||||
@@ -308,6 +454,33 @@ pub async fn wire(registry: ResourceRegistry, dial: alktunnels::producer::DialFn
|
||||
.await
|
||||
}
|
||||
|
||||
/// The direct-op topology (ADR-007): the direct op registered with
|
||||
/// the CF-006 witness; the consumer's transport identity is
|
||||
/// [`both_scopes_identity`] (both grants — the opens authorize).
|
||||
pub async fn wire_direct(dial: alktunnels::producer::DialFn) -> Topology {
|
||||
wire_with(
|
||||
ResourceRegistry::new(),
|
||||
RegistrationMode::Direct(dial),
|
||||
Some(both_scopes_identity()),
|
||||
None,
|
||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`wire_direct`] with an explicit transport identity (the
|
||||
/// scope-separation probes construct their own identities).
|
||||
pub async fn wire_direct_with_identity(identity: Identity) -> Topology {
|
||||
wire_with(
|
||||
ResourceRegistry::new(),
|
||||
RegistrationMode::Direct(failing_dial("unused")),
|
||||
Some(identity),
|
||||
None,
|
||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// A topology with the consumer's transport identity REPLACED by the
|
||||
/// serving-side override (`ServingConfig.identity` — CF-005 (a) probe
|
||||
/// shape) or stripped entirely (the fail-closed probe). The transport
|
||||
@@ -345,6 +518,137 @@ pub async fn wire_listen(
|
||||
.await
|
||||
}
|
||||
|
||||
/// The bind-first topology (shape 3, ADR-008): the producer's
|
||||
/// establisher binds via the injected [`BindFn`]; the accept
|
||||
/// dispatcher runs the injected accept loop and opens forwarded
|
||||
/// channels back toward the opener (whose serving stub the test
|
||||
/// installs on the consumer side — see
|
||||
/// [`serve_forwarded_on_consumer`]). Same identity posture as
|
||||
/// [`wire`], plus the forwarded scope (the opener-side ingress grant
|
||||
/// — the consumer identity must hold `tunnel:forwarded` to receive
|
||||
/// producer-initiated forwarded opens).
|
||||
pub async fn wire_bindfirst(
|
||||
registry: ResourceRegistry,
|
||||
bind: alktunnels::producer::BindFn,
|
||||
accept_loop: alktunnels::producer::BindFirstAcceptLoop,
|
||||
) -> Topology {
|
||||
wire_with(
|
||||
registry,
|
||||
RegistrationMode::BindFirst(bind, accept_loop),
|
||||
Some(forwarded_opener_identity()),
|
||||
None,
|
||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The forwarded-op opener identity: `tunnel:forwarded` AND
|
||||
/// `tunnel:open` (the opener receives producer-initiated forwarded
|
||||
/// opens — the ingress grant — and opens the listen tunnel itself —
|
||||
/// the egress grant; ADR-008 §3 / ADR-006 Amendment 1).
|
||||
pub fn forwarded_opener_identity() -> Identity {
|
||||
Identity {
|
||||
id: "opener".to_string(),
|
||||
scopes: vec![
|
||||
TUNNEL_OPEN_SCOPE.to_string(),
|
||||
TUNNEL_FORWARDED_SCOPE.to_string(),
|
||||
],
|
||||
resources: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`forwarded_opener_identity`] minus the forwarded grant (the
|
||||
/// per-accept isolation probe's identity — the forwarded open fails
|
||||
/// FORBIDDEN).
|
||||
pub fn forwarded_scopeless_identity() -> Identity {
|
||||
Identity {
|
||||
id: "opener-scopeless".to_string(),
|
||||
scopes: vec![TUNNEL_OPEN_SCOPE.to_string()],
|
||||
resources: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the forwarded op (`channels/tunnel/forwarded`, ADR-008 §3) on
|
||||
/// the CONSUMER side (the listen opener's serving stub — the test
|
||||
/// stand-in for the opener-side registration that lands with
|
||||
/// `tunnels/forwarded-receipt`): a `register_openable` (no
|
||||
/// establisher — the data plane starts immediately) whose handler
|
||||
/// correlates the open by the `listen` token against the test's
|
||||
/// correlation map, accepts the `BiStream`, and pumps against the
|
||||
/// "local BIND client" halves the test registered for that token.
|
||||
///
|
||||
/// `correlations` maps `listen` token → the pump side to pump the
|
||||
/// forwarded channel against (the assembly-side bookkeeping posture —
|
||||
/// the protocol provides the op + params, the test provides the map).
|
||||
/// The registration is post-hoc on the consumer's captured registry —
|
||||
/// but the CONSUMER serves no ops in the default harness (its
|
||||
/// dispatch registry is empty and the dispatcher runs on it), so this
|
||||
/// helper must be called BEFORE the topology is wired; hence it is a
|
||||
/// registry-level helper the caller wires via a custom `wire_with`
|
||||
/// variant when needed.
|
||||
pub fn register_forwarded_stub_on_consumer(
|
||||
consumer_registry: &Arc<OperationRegistry>,
|
||||
manager: ChannelManager,
|
||||
correlations: CorrelationMap,
|
||||
) -> Result<(), String> {
|
||||
let core = alkcall::channels::operations::ChannelCore::new(
|
||||
manager,
|
||||
alkcall::channels::policy::default_policy(),
|
||||
);
|
||||
core.register_openable(
|
||||
alktunnels::params::tunnel_forwarded_spec(),
|
||||
make_forwarded_stub_handler(correlations),
|
||||
consumer_registry,
|
||||
AuthContext::anonymous(b"alk/tunnel"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The correlation map: `listen` token → the TargetHandle the
|
||||
/// forwarded channel pumps against (the "local BIND client" halves).
|
||||
pub type CorrelationMap =
|
||||
Arc<tokio::sync::Mutex<std::collections::HashMap<u32, alktunnels::producer::TargetHandle>>>;
|
||||
|
||||
/// The forwarded-op handler (the opener-side serving stub's handler):
|
||||
/// correlate by `listen` → accept the `BiStream` → pump against the
|
||||
/// correlated halves (`pump_bidi`, convention 8). A correlation miss
|
||||
/// is a loud `handler_error`-class open failure (the typed posture —
|
||||
/// the accept dies at birth; the loop keeps serving).
|
||||
pub fn make_forwarded_stub_handler(
|
||||
correlations: CorrelationMap,
|
||||
) -> alkcall::channels::operations::OpenHandler {
|
||||
Arc::new(
|
||||
move |input: Value,
|
||||
_plan: Option<alkcall::channels::operations::ChannelPlan>,
|
||||
conn: alkcall::core::types::Connection,
|
||||
_auth: AuthContext| {
|
||||
let correlations = Arc::clone(&correlations);
|
||||
tokio::spawn(async move {
|
||||
let forwarded: alktunnels::params::TunnelForwardedParams =
|
||||
match serde_json::from_value(input) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "forwarded stub: params parse failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let target = correlations.lock().await.remove(&forwarded.listen);
|
||||
let Some(target) = target else {
|
||||
tracing::error!(
|
||||
listen = forwarded.listen,
|
||||
"forwarded stub: correlation miss"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(bidi) = conn.accept_bi().await.ok() else {
|
||||
tracing::error!("forwarded stub: accept_bi failed");
|
||||
return;
|
||||
};
|
||||
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The forward topology (the `-L` direction): the consumer dials the
|
||||
/// transport (connect side — `ChannelClient::from_connection`, a pure
|
||||
/// consumer serving nothing) and the producer accepts (the adapter +
|
||||
@@ -472,6 +776,319 @@ pub async fn wire_forward(
|
||||
}
|
||||
}
|
||||
|
||||
/// The DIRECT-op forward topology (ADR-007): the producer (accept
|
||||
/// side, serving) registers the direct op — [`register_tunnel_direct_openable`]
|
||||
/// over the injected dial, CF-006 witness attached — and the consumer
|
||||
/// (connect side, pure client) holds the identity the session tests
|
||||
/// authorize with. This is the topology `TunnelSession::open_direct`
|
||||
/// is normative for.
|
||||
pub async fn wire_direct_forward(dial: alktunnels::producer::DialFn) -> ForwardTopology {
|
||||
wire_direct_forward_with_identity(dial, both_scopes_identity()).await
|
||||
}
|
||||
|
||||
/// [`wire_direct_forward`] with an explicit transport identity (the
|
||||
/// session-level scope-separation probes construct their own).
|
||||
pub async fn wire_direct_forward_with_identity(
|
||||
dial: alktunnels::producer::DialFn,
|
||||
identity: Identity,
|
||||
) -> ForwardTopology {
|
||||
let producer_op_registry = Arc::new(OperationRegistry::new());
|
||||
let (producer_manager_tx, mut producer_manager_rx) =
|
||||
tokio::sync::mpsc::channel::<ChannelManager>(1);
|
||||
let identity_witness = Arc::new(tokio::sync::Mutex::new(None::<String>));
|
||||
let dial_witness = Arc::clone(&identity_witness);
|
||||
let expected_caller = identity.clone();
|
||||
let install_hook: InstallChannelZero = Arc::new(move |manager, channel0_conn, _auth| {
|
||||
let registry_arc = Arc::clone(&producer_op_registry);
|
||||
let dial = Arc::clone(&dial);
|
||||
let witness = Arc::clone(&dial_witness);
|
||||
let manager_tx = producer_manager_tx.clone();
|
||||
let caller = expected_caller.clone();
|
||||
tokio::spawn(async move {
|
||||
let channel0_bidi = match channel0_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
// The accept-side transport-identity posture: the transport
|
||||
// authenticated the dialer; attach the RESOLVED identity
|
||||
// (scopes included — resolved out-of-band) to channel 0.
|
||||
let _ = channel0_conn.set_identity(caller);
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
let core = alkcall::channels::operations::ChannelCore::new(
|
||||
manager.clone(),
|
||||
alkcall::channels::policy::default_policy(),
|
||||
);
|
||||
let ops = ChannelOperations::with_default_policy(manager.clone());
|
||||
ops.register_on(®istry_arc)
|
||||
.expect("register channel ops on producer");
|
||||
register_tunnel_direct_openable(
|
||||
&core,
|
||||
®istry_arc,
|
||||
AuthContext::anonymous(b"alk/tunnel"),
|
||||
dial,
|
||||
Some(witness),
|
||||
)
|
||||
.expect("register tunnel direct openable");
|
||||
let _ = manager_tx.send(manager).await;
|
||||
let dp = Dispatcher::new(
|
||||
registry_arc,
|
||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||
);
|
||||
dp.serve_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
|
||||
let (consumer_end, producer_end) = tokio::io::duplex(64 * 1024);
|
||||
let consumer_conn = CoreConnection::from_bidi(consumer_end, b"alk/channels".to_vec(), None);
|
||||
let producer_conn = CoreConnection::from_bidi(producer_end, b"alk/channels".to_vec(), None);
|
||||
consumer_conn
|
||||
.set_identity(identity)
|
||||
.expect("transport identity set once");
|
||||
|
||||
let adapter = ChannelsAdapter::new(install_hook, Arc::new(alkcall::channels::policy::NoCap));
|
||||
let producer_transport_auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _adapter_task = tokio::spawn(async move {
|
||||
let _ = alkcall::core::types::ProtocolHandler::handle(
|
||||
&adapter,
|
||||
producer_conn,
|
||||
&producer_transport_auth,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let consumer_client = ChannelClient::from_connection(consumer_conn)
|
||||
.await
|
||||
.expect("consumer channel client init");
|
||||
let consumer_client = Arc::new(consumer_client);
|
||||
let producer_manager = producer_manager_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("producer manager captured");
|
||||
|
||||
ForwardTopology {
|
||||
consumer: consumer_client,
|
||||
producer_manager,
|
||||
identity_witness,
|
||||
}
|
||||
}
|
||||
|
||||
/// The listen-flow topology (ADR-008 §4 — the `TunnelListener` shape):
|
||||
/// the OPENER dials the transport (connect side —
|
||||
/// `ChannelClient::from_connection_with_serving`, so it can both open
|
||||
/// the listen tunnel and serve the forwarded op), and the producer
|
||||
/// accepts (the adapter + an install hook registering the bind-first
|
||||
/// openable over the injected bind + accept loop). The producer-side
|
||||
/// forwarded-open caller rides the accept-side call plane (the
|
||||
/// serving session's `CallConnection` — the same surface the serving
|
||||
/// dispatch loop runs on; both-sides sanity, ADR-022 §2) and adopts on
|
||||
/// the producer-side manager. The forwarded op registers post-hoc (W2)
|
||||
/// on the opener's captured `opener_registry`.
|
||||
pub struct ListenerTopology {
|
||||
/// The opener's own client (connect side, serving). Its manager
|
||||
/// adopted the producer-allocated listen channel ID (via
|
||||
/// `open_channel_with_reply`); the listener reaps via this
|
||||
/// manager.
|
||||
pub opener: Arc<ChannelClient>,
|
||||
/// The opener's serving registry (the forwarded op registers here
|
||||
/// post-hoc).
|
||||
pub opener_registry: Arc<OperationRegistry>,
|
||||
/// The producer's manager (accept side, serving) — for asserting
|
||||
/// wrapper-managed reaping + for the test's adopt dance.
|
||||
pub producer_manager: ChannelManager,
|
||||
/// The producer-side call plane (`Arc<CallConnection>`) — the
|
||||
/// forwarded-op caller's surface (and the test's direct-call
|
||||
/// surface for the adopt-pin dance).
|
||||
pub producer_call: Arc<CallConnection>,
|
||||
/// What the establisher's per-call auth carried (the CF-006 probe).
|
||||
pub identity_witness: Arc<tokio::sync::Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
pub async fn wire_listener_bindfirst(
|
||||
registry: ResourceRegistry,
|
||||
bind: alktunnels::producer::BindFn,
|
||||
accept_loop: alktunnels::producer::BindFirstAcceptLoop,
|
||||
opener_identity: Identity,
|
||||
) -> ListenerTopology {
|
||||
use alktunnels::producer::{
|
||||
register_tunnel_bindfirst_openable, ForwardedOpener, TunnelEstablishError,
|
||||
};
|
||||
|
||||
let producer_op_registry = Arc::new(OperationRegistry::new());
|
||||
let opener_op_registry = Arc::new(OperationRegistry::new());
|
||||
let (producer_manager_tx, mut producer_manager_rx) =
|
||||
tokio::sync::mpsc::channel::<ChannelManager>(1);
|
||||
let (producer_call_tx, mut producer_call_rx) =
|
||||
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
|
||||
let identity_witness = Arc::new(tokio::sync::Mutex::new(None::<String>));
|
||||
let bind_witness = Arc::clone(&identity_witness);
|
||||
let opener_identity_for_hook = opener_identity.clone();
|
||||
|
||||
// --- producer (accept side, serving): adapter + install hook ------
|
||||
let install_hook: InstallChannelZero = Arc::new(move |manager, channel0_conn, _auth| {
|
||||
let registry_arc = Arc::clone(&producer_op_registry);
|
||||
let resource_registry = registry.clone();
|
||||
let bind = Arc::clone(&bind);
|
||||
let accept_loop = Arc::clone(&accept_loop);
|
||||
let witness = Arc::clone(&bind_witness);
|
||||
let manager_tx = producer_manager_tx.clone();
|
||||
let call_tx = producer_call_tx.clone();
|
||||
let caller = opener_identity_for_hook.clone();
|
||||
tokio::spawn(async move {
|
||||
let channel0_bidi = match channel0_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
// The accept-side transport-identity posture (modeled): the
|
||||
// transport authenticated the dialer (the opener); attach
|
||||
// the resolved identity to channel 0 so the serving
|
||||
// dispatch sees the caller (CF-005 (b)'s accept-side
|
||||
// analogue).
|
||||
let _ = channel0_conn.set_identity(caller);
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
));
|
||||
|
||||
// Register the generic channel ops + the bind-first
|
||||
// openable (the producer.md recipe) before the dispatch
|
||||
// loop runs.
|
||||
let core = alkcall::channels::operations::ChannelCore::new(
|
||||
manager.clone(),
|
||||
alkcall::channels::policy::default_policy(),
|
||||
);
|
||||
let ops = ChannelOperations::with_default_policy(manager.clone());
|
||||
ops.register_on(®istry_arc)
|
||||
.expect("register channel ops on producer");
|
||||
|
||||
// The forwarded-open caller (the call plane): call + adopt
|
||||
// (the reverse POC's `open_reverse_channel` shape — the
|
||||
// serving session's `CallConnection` is the per-accept call
|
||||
// plane; the producer-side manager adopts the
|
||||
// opener-allocated ID).
|
||||
let call_for_opener = Arc::clone(&call_connection);
|
||||
let manager_for_opener = manager.clone();
|
||||
let open_forwarded: ForwardedOpener = Arc::new(
|
||||
move |forwarded: alktunnels::params::TunnelForwardedParams| {
|
||||
let call = Arc::clone(&call_for_opener);
|
||||
let manager = manager_for_opener.clone();
|
||||
Box::pin(async move {
|
||||
let input = serde_json::to_value(&forwarded).map_err(|e| {
|
||||
TunnelEstablishError::HandlerError(format!(
|
||||
"forwarded params serialize: {e}"
|
||||
))
|
||||
})?;
|
||||
let response = call
|
||||
.call(alktunnels::params::OP_TUNNEL_FORWARDED, input)
|
||||
.await;
|
||||
let out = response.result.map_err(|e| {
|
||||
TunnelEstablishError::HandlerError(format!(
|
||||
"forwarded open: {}",
|
||||
e.message
|
||||
))
|
||||
})?;
|
||||
let channel_id =
|
||||
out.get("channel_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| {
|
||||
TunnelEstablishError::HandlerError(
|
||||
"forwarded open reply missing channel_id".to_string(),
|
||||
)
|
||||
})? as u32;
|
||||
let (send, recv) = manager
|
||||
.adopt_channel(channel_id, alktunnels::params::TUNNEL_ALPN, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
TunnelEstablishError::HandlerError(format!("forwarded adopt: {e}"))
|
||||
})?;
|
||||
Ok((send, recv))
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
register_tunnel_bindfirst_openable(
|
||||
&core,
|
||||
&resource_registry,
|
||||
®istry_arc,
|
||||
AuthContext::anonymous(b"alk/tunnel"),
|
||||
alktunnels::producer::BindFirstInjections {
|
||||
bind,
|
||||
accept_loop,
|
||||
open_forwarded,
|
||||
},
|
||||
Some(witness),
|
||||
)
|
||||
.expect("register tunnel bindfirst openable");
|
||||
let _ = manager_tx.send(manager).await;
|
||||
let _ = call_tx.send(Arc::clone(&call_connection)).await;
|
||||
|
||||
let dp = Dispatcher::new(
|
||||
registry_arc,
|
||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||
);
|
||||
dp.serve_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
|
||||
// --- transport ------------------------------------------------------
|
||||
let (opener_end, producer_end) = tokio::io::duplex(64 * 1024);
|
||||
let opener_conn = CoreConnection::from_bidi(opener_end, b"alk/channels".to_vec(), None);
|
||||
let producer_conn = CoreConnection::from_bidi(producer_end, b"alk/channels".to_vec(), None);
|
||||
// The dialer's transport identity (the mTLS/QUIC posture) — set
|
||||
// before from_connection_with_serving. The accept side resolves it
|
||||
// out-of-band (modeled in the hook above).
|
||||
opener_conn
|
||||
.set_identity(opener_identity.clone())
|
||||
.expect("transport identity set once");
|
||||
|
||||
let adapter = ChannelsAdapter::new(install_hook, Arc::new(alkcall::channels::policy::NoCap));
|
||||
let producer_transport_auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _adapter_task = tokio::spawn(async move {
|
||||
let _ = alkcall::core::types::ProtocolHandler::handle(
|
||||
&adapter,
|
||||
producer_conn,
|
||||
&producer_transport_auth,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
// --- opener (connect side): client + serving (the forwarded op) -----
|
||||
let opener_client = ChannelClient::from_connection_with_serving(
|
||||
opener_conn,
|
||||
Some(ServingConfig {
|
||||
registry: Arc::clone(&opener_op_registry),
|
||||
identity_provider: Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||
identity: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("opener channel client init");
|
||||
let opener_client = Arc::new(opener_client);
|
||||
|
||||
let producer_manager = producer_manager_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("producer manager captured");
|
||||
let producer_call = producer_call_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("producer call plane captured");
|
||||
|
||||
ListenerTopology {
|
||||
opener: opener_client,
|
||||
opener_registry: opener_op_registry,
|
||||
producer_manager,
|
||||
producer_call,
|
||||
identity_witness,
|
||||
}
|
||||
}
|
||||
|
||||
/// An echo dial closure (the ADR-004 injection point under test): an
|
||||
/// in-process pipe per dial — the "target" echoes bytes until EOF.
|
||||
/// The consumer side gets split `DuplexStream` halves; a spawned task
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
//! Tests for the `local` feature's BIND-FIRST half (shape 3, ADR-008):
|
||||
//! the real-socket `BindFn` sources (`bind_tcp_first` /
|
||||
//! `bind_unix_first` — the `bound` reply field's data, port 0
|
||||
//! resolved), the accept-pair loops (peer + halves per accept), and
|
||||
//! the assembly convenience closures (`local_bind_first` +
|
||||
//! `local_bindfirst_accept_loop`), through the real bind-first
|
||||
//! establisher path over REAL sockets.
|
||||
//!
|
||||
//! Covered (the task's test plan): the bound address is the REAL
|
||||
//! OS-chosen one (a fake `bound` would strand the test's dials), two
|
||||
//! clients dial → two accepts → two forwarded channels pumping
|
||||
//! independently (peer fidelity: each forwarded open carries the REAL
|
||||
//! accepted peer), unix bind-first parity (OQ-TN-14 — the bind path is
|
||||
//! the same shape as the dial path), and the udp bind-first refusal
|
||||
//! (a UDP socket has no accept).
|
||||
|
||||
#![cfg(feature = "local")]
|
||||
|
||||
mod harness;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use alkcall::channels::operations::{ChannelCore, OpenHandler};
|
||||
use alkcall::core::auth::AuthContext;
|
||||
use alkcall::protocol::wire::CallError;
|
||||
use alktunnels::params::{parse_listen_reply, Substrate, SubstrateAddr, TunnelForwardedParams};
|
||||
use alktunnels::producer::ResourceRegistry;
|
||||
use serde_json::Value;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
fn init_tracing() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.try_init();
|
||||
}
|
||||
|
||||
/// The forwarded-op recording stub's peer log: the `peer` param of
|
||||
/// every forwarded open the producer's dispatcher made (the
|
||||
/// reply#2-fidelity probe surface — ADR-008 §3).
|
||||
type PeerLog = Arc<tokio::sync::Mutex<Vec<SubstrateAddr>>>;
|
||||
|
||||
/// The forwarded-op opener-side serving stub (the queue-fed shape):
|
||||
/// each forwarded open pops the NEXT app-side pair (the "local BIND
|
||||
/// client" halves the test registered) and pumps the forwarded
|
||||
/// channel against it, recording the open's `peer` param (the
|
||||
/// reply#2-fidelity probe surface — ADR-008 §3). One listener session
|
||||
/// = one token = MANY forwarded opens (per accept), so the
|
||||
/// correlation is a queue, not a one-shot map.
|
||||
fn recording_forwarded_handler(app_sides: AppSideQueue, peers: PeerLog) -> OpenHandler {
|
||||
Arc::new(
|
||||
move |input: Value, _plan, conn: alkcall::core::types::Connection, _auth| {
|
||||
let app_sides = Arc::clone(&app_sides);
|
||||
let peers = Arc::clone(&peers);
|
||||
tokio::spawn(async move {
|
||||
let forwarded: TunnelForwardedParams = match serde_json::from_value(input) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "recording stub: params parse failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
peers.lock().await.push(forwarded.peer.clone());
|
||||
let target = app_sides.lock().await.pop_front();
|
||||
let Some(target) = target else {
|
||||
tracing::error!(
|
||||
listen = forwarded.listen,
|
||||
"recording stub: no app-side pair registered"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Ok(bidi) = conn.accept_bi().await else {
|
||||
tracing::error!("recording stub: accept_bi failed");
|
||||
return;
|
||||
};
|
||||
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The app-side pairs the stub pops per forwarded open (the "local
|
||||
/// BIND client" halves — the stub's pump target; the far end is the
|
||||
/// test's echo task).
|
||||
type AppSideQueue =
|
||||
Arc<tokio::sync::Mutex<std::collections::VecDeque<alktunnels::producer::TargetHandle>>>;
|
||||
|
||||
/// Install the recording stub on the opener (consumer) side (post-hoc
|
||||
/// on the captured serving registry — W2).
|
||||
async fn install_recording_stub(topo: &harness::Topology) -> (AppSideQueue, PeerLog) {
|
||||
let app_sides: AppSideQueue = Arc::new(tokio::sync::Mutex::new(Default::default()));
|
||||
let peers: PeerLog = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let core = ChannelCore::new(
|
||||
topo.consumer_manager.clone(),
|
||||
alkcall::channels::policy::default_policy(),
|
||||
);
|
||||
core.register_openable(
|
||||
alktunnels::params::tunnel_forwarded_spec(),
|
||||
recording_forwarded_handler(Arc::clone(&app_sides), Arc::clone(&peers)),
|
||||
&topo.consumer_registry,
|
||||
AuthContext::anonymous(b"alk/tunnel"),
|
||||
)
|
||||
.expect("register recording forwarded stub on the opener");
|
||||
(app_sides, peers)
|
||||
}
|
||||
|
||||
/// Call the producer's open op on channel 0 with the FULL reply (the
|
||||
/// bind-first reply projection probe — `bound` + `listen`).
|
||||
async fn open_listen_with_reply(
|
||||
topo: &harness::Topology,
|
||||
resource: &str,
|
||||
substrate: Substrate,
|
||||
) -> Result<(u32, Value), CallError> {
|
||||
let substrate_str = match substrate {
|
||||
Substrate::Tcp => "tcp",
|
||||
Substrate::Udp => "udp",
|
||||
Substrate::Unix => "unix",
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
"operationId": "channels/tunnel/sub",
|
||||
"input": {
|
||||
"resource": resource,
|
||||
"substrate": substrate_str,
|
||||
},
|
||||
});
|
||||
let response = topo.consumer_call.call_with_payload(payload).await;
|
||||
let out = response.result?;
|
||||
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
||||
Ok((channel_id, out))
|
||||
}
|
||||
|
||||
/// One app-side session: a raw duplex pair whose FAR end echoes (the
|
||||
/// app-side session shape) — the stub pumps the forwarded channel
|
||||
/// against the near end (pushed into the stub's queue); the test
|
||||
/// drives the REAL accepted socket at the tunnel's other side, whose
|
||||
/// bytes echo through the app-side session.
|
||||
fn echo_app_side() -> alktunnels::producer::TargetHandle {
|
||||
let (a, b) = tokio::io::duplex(64 * 1024);
|
||||
let (a_read, a_write) = tokio::io::split(a);
|
||||
let (b_read, b_write) = tokio::io::split(b);
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let (mut r, mut w) = (b_read, b_write);
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match r.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => {
|
||||
if w.write_all(&buf[..n]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
alktunnels::producer::TargetHandle {
|
||||
read: Box::new(a_read),
|
||||
write: Box::new(a_write),
|
||||
}
|
||||
}
|
||||
|
||||
/// THE bind-first flow over REAL TCP sockets (the `local` shape-3
|
||||
/// gate): `bind_tcp_first` binds port 0 → the reply's `bound` is the
|
||||
/// REAL OS-chosen address (the test dials it — a fake bound would
|
||||
/// strand the dial) → two clients dial → two accepts → two forwarded
|
||||
/// channels → both pump independently, and each forwarded open
|
||||
/// carries the REAL accepted peer (peer fidelity, reply#2 class).
|
||||
#[tokio::test]
|
||||
async fn bindfirst_tcp_real_sockets_bound_fidelity_and_two_pumps() {
|
||||
init_tracing();
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "127.0.0.1:0")
|
||||
.await;
|
||||
let topo = harness::wire_bindfirst(
|
||||
registry,
|
||||
alktunnels::local::local_bind_first(),
|
||||
alktunnels::local::local_bindfirst_accept_loop(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (channel_id, reply) = open_listen_with_reply(&topo, "bind", Substrate::Tcp)
|
||||
.await
|
||||
.expect("listen open");
|
||||
let projected = parse_listen_reply(&reply).expect("projected reply");
|
||||
let bind_addr = match &projected.bound {
|
||||
Some(SubstrateAddr::HostPort { host, port }) => {
|
||||
assert_eq!(host, "127.0.0.1", "the bound host is the bound loopback");
|
||||
assert_ne!(*port, 0, "port 0 was resolved to the OS-chosen port");
|
||||
format!("{host}:{port}")
|
||||
}
|
||||
other => panic!("bind-first open carries the real bound address: {other:?}"),
|
||||
};
|
||||
let token = projected.listen.expect("the `listen` token");
|
||||
assert_ne!(token, 0, "token 0 is never minted (the wire-illegal value)");
|
||||
|
||||
let (app_sides, peers) = install_recording_stub(&topo).await;
|
||||
|
||||
// The opener adopts the LISTEN channel (the idle side). The
|
||||
// halves are HELD — dropping them EOFs the idle stream and tears
|
||||
// the listener down (the ADR-008 sentinel is live even here).
|
||||
let (_idle_send, _idle_recv) = topo
|
||||
.consumer_manager
|
||||
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
|
||||
.await
|
||||
.expect("adopt listen channel");
|
||||
|
||||
// Two app-side sessions (the stub pops one per forwarded open).
|
||||
app_sides.lock().await.push_back(echo_app_side());
|
||||
app_sides.lock().await.push_back(echo_app_side());
|
||||
|
||||
// Two clients dial the reported bound address (the reply#1
|
||||
// fidelity pin: `bound` is the REAL address, not a placeholder).
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut client1 = tokio::net::TcpStream::connect(&bind_addr)
|
||||
.await
|
||||
.expect("client 1 dials the bound address");
|
||||
let mut client2 = tokio::net::TcpStream::connect(&bind_addr)
|
||||
.await
|
||||
.expect("client 2 dials the bound address");
|
||||
|
||||
// Both pump independently: interleaved writes, per-socket echoes.
|
||||
client1.write_all(b"client-one").await.expect("write 1");
|
||||
client2.write_all(b"client-two").await.expect("write 2");
|
||||
let mut buf1 = vec![0u8; 10];
|
||||
let mut buf2 = vec![0u8; 10];
|
||||
let (r1, r2) = tokio::join!(
|
||||
tokio::time::timeout(Duration::from_secs(5), client1.read_exact(&mut buf1)),
|
||||
tokio::time::timeout(Duration::from_secs(5), client2.read_exact(&mut buf2)),
|
||||
);
|
||||
r1.expect("round trip 1").expect("read 1");
|
||||
r2.expect("round trip 2").expect("read 2");
|
||||
assert_eq!(&buf1, b"client-one", "pump 1 serves ITS socket");
|
||||
assert_eq!(&buf2, b"client-two", "pump 2 serves ITS socket");
|
||||
|
||||
// Peer fidelity: each forwarded open carried the REAL accepted
|
||||
// peer (loopback + a real ephemeral port; distinct per socket).
|
||||
let logged = peers.lock().await.clone();
|
||||
assert_eq!(logged.len(), 2, "one forwarded open per accept");
|
||||
let mut ports = std::collections::HashSet::new();
|
||||
for peer in &logged {
|
||||
match peer {
|
||||
SubstrateAddr::HostPort { host, port } => {
|
||||
assert_eq!(host, "127.0.0.1", "the REAL peer host");
|
||||
assert_ne!(*port, 0, "the REAL peer port");
|
||||
ports.insert(*port);
|
||||
}
|
||||
other => panic!("tcp peer is HostPort-shaped: {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(ports.len(), 2, "two accepts carried distinct real peers");
|
||||
}
|
||||
|
||||
/// Unix bind-first parity (OQ-TN-14 — the bind path ships with the
|
||||
/// dial path): `bind_unix_first` binds a temp-path listener → the
|
||||
/// reply's `bound` is the REAL bound socket path → a unix client
|
||||
/// (bound to its own path, so the peer is representable) connects →
|
||||
/// one accept → one forwarded channel carrying the REAL peer path →
|
||||
/// byte round-trip.
|
||||
#[tokio::test]
|
||||
async fn bindfirst_unix_real_sockets_bound_and_pumped() {
|
||||
init_tracing();
|
||||
let dir = std::env::temp_dir().join(format!("alktunnels-bindfirst-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let listen_path = dir.join("listen.sock");
|
||||
let client_path = dir.join("client.sock");
|
||||
let _ = std::fs::remove_file(&listen_path);
|
||||
let _ = std::fs::remove_file(&client_path);
|
||||
let listen_str = listen_path.to_string_lossy().into_owned();
|
||||
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("ubind", Substrate::Unix, &listen_str)
|
||||
.await;
|
||||
let topo = harness::wire_bindfirst(
|
||||
registry,
|
||||
alktunnels::local::local_bind_first(),
|
||||
alktunnels::local::local_bindfirst_accept_loop(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (channel_id, reply) = open_listen_with_reply(&topo, "ubind", Substrate::Unix)
|
||||
.await
|
||||
.expect("listen open");
|
||||
let projected = parse_listen_reply(&reply).expect("projected reply");
|
||||
assert_eq!(
|
||||
projected.bound,
|
||||
Some(SubstrateAddr::Path {
|
||||
path: listen_str.clone()
|
||||
}),
|
||||
"the `bound` field is the REAL bound socket path"
|
||||
);
|
||||
let token = projected.listen.expect("the `listen` token");
|
||||
assert_ne!(token, 0, "token 0 is never minted (the wire-illegal value)");
|
||||
|
||||
let (app_sides, peers) = install_recording_stub(&topo).await;
|
||||
|
||||
// The opener adopts the LISTEN channel (the idle side) — held for
|
||||
// the test's lifetime (dropping the halves EOFs the idle stream
|
||||
// and tears the listener down, the ADR-008 sentinel).
|
||||
let (_idle_send, _idle_recv) = topo
|
||||
.consumer_manager
|
||||
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
|
||||
.await
|
||||
.expect("adopt listen channel");
|
||||
|
||||
// One app-side session (the stub pops it for the forwarded open).
|
||||
app_sides.lock().await.push_back(echo_app_side());
|
||||
|
||||
// The unix client binds its own socket at a named path FIRST (an
|
||||
// unnamed peer cannot be represented in the forwarded `peer`
|
||||
// param — ADR-008 §3; the named-peer shape is the parity case).
|
||||
// socket2: std has no `UnixStream::bind`; a socket2 socket binds
|
||||
// the sockaddr at the path, then connects (the peer address is
|
||||
// the client's own bound path).
|
||||
let client_sock = socket2::Socket::new(socket2::Domain::UNIX, socket2::Type::STREAM, None)
|
||||
.expect("socket2 client");
|
||||
client_sock
|
||||
.bind(&socket2::SockAddr::unix(&client_path).expect("client sockaddr"))
|
||||
.expect("bind client path");
|
||||
client_sock
|
||||
.connect(&socket2::SockAddr::unix(&listen_path).expect("listen sockaddr"))
|
||||
.expect("client dials");
|
||||
let client_std: std::os::unix::net::UnixStream = client_sock.into();
|
||||
client_std.set_nonblocking(true).expect("nonblocking");
|
||||
let mut client = tokio::net::UnixStream::from_std(client_std).expect("tokio client");
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
client.write_all(b"unix-bind").await.expect("write");
|
||||
let mut buf = vec![0u8; 9];
|
||||
tokio::time::timeout(Duration::from_secs(5), client.read_exact(&mut buf))
|
||||
.await
|
||||
.expect("round trip")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, b"unix-bind");
|
||||
|
||||
// Peer fidelity: the forwarded open carried the client's REAL
|
||||
// bound path.
|
||||
let logged = peers.lock().await.clone();
|
||||
assert_eq!(
|
||||
logged,
|
||||
vec![SubstrateAddr::Path {
|
||||
path: client_path.to_string_lossy().into_owned()
|
||||
}],
|
||||
"the forwarded open carried the REAL accepted peer path"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&listen_path);
|
||||
let _ = std::fs::remove_file(&client_path);
|
||||
}
|
||||
|
||||
/// The udp bind-first refusal: a UDP socket has no accept, so the
|
||||
/// bind closure fails loud (`handler_error`-class) — the shape is not
|
||||
/// defined there, never a silent fake listener.
|
||||
#[tokio::test]
|
||||
async fn udp_bind_first_fails_loud() {
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("ubind", Substrate::Udp, "127.0.0.1:0")
|
||||
.await;
|
||||
let topo = harness::wire_bindfirst(
|
||||
registry,
|
||||
alktunnels::local::local_bind_first(),
|
||||
alktunnels::local::local_bindfirst_accept_loop(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"operationId": "channels/tunnel/sub",
|
||||
"input": {"resource": "ubind", "substrate": "udp"},
|
||||
});
|
||||
let response = topo.consumer_call.call_with_payload(payload).await;
|
||||
let err = response.result.expect_err("udp bind-first must fail");
|
||||
assert_eq!(err.code, "channel:open_failed");
|
||||
let reason = err
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("reason"))
|
||||
.and_then(|r| r.as_str())
|
||||
.expect("reason in details");
|
||||
assert_eq!(reason, "handler_error");
|
||||
}
|
||||
|
||||
/// The accept-pair loop's mismatch probe: a listener NOT produced by
|
||||
/// this module's bind closures fails loud inside the closure (the
|
||||
/// downcast is the assembly side of the type-erased injection — a
|
||||
/// foreign listener type is `handler_error`, never a phantom accept).
|
||||
#[tokio::test]
|
||||
async fn accept_pair_loop_downcast_miss_fails_loud() {
|
||||
let loop_ = alktunnels::local::local_bindfirst_accept_loop();
|
||||
let foreign: Arc<dyn std::any::Any + Send + Sync> = Arc::new(4242u32);
|
||||
let err = loop_(foreign)
|
||||
.await
|
||||
.expect_err("a foreign listener fails loud");
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
alktunnels::producer::TunnelEstablishError::HandlerError(_)
|
||||
),
|
||||
"the downcast miss is handler_error-class: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The unnamed-peer/unnamed-bind fail-loud arms (G-5 — ADR-008 §3's
|
||||
/// two pinned sentences: a producer that cannot determine the peer
|
||||
/// does not serve a bind-first listener over that substrate):
|
||||
///
|
||||
/// - **Unnamed bind** — an empty-path `bind_unix` is the kernel's
|
||||
/// autobind (the kernel assigns an abstract name, addlen ==
|
||||
/// offsetof(sun_path)); the bound addr has no filesystem path, so
|
||||
/// `local_addr` (and thus `bind_unix_first`) fails loud with the
|
||||
/// "no filesystem path" handler error instead of serving an
|
||||
/// unrepresentable `bound`.
|
||||
/// - **Unnamed peer** — an addressless client (socket2 autobind
|
||||
/// here; a plain unbound client behaves identically through tokio
|
||||
/// accept — mio maps any abstract-namespace peer to unnamed) is
|
||||
/// accepted by a NAMED listener; `accept_pair` fails loud because
|
||||
/// the forwarded op's `peer` param is required on the wire.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn unix_unnamed_bind_and_unnamed_peer_fail_loud() {
|
||||
// Arm 1: the autobind listener — `bind_unix("")`. tokio passes the
|
||||
// empty path through to `bind(2)` with addlen == offsetof(sun_path):
|
||||
// the kernel autobinds an abstract name, no filesystem path exists.
|
||||
let halves = alktunnels::local::bind_unix("")
|
||||
.expect("autobind bind succeeds (the kernel assigns the abstract name)");
|
||||
let err = halves
|
||||
.local_addr()
|
||||
.expect_err("an autobound listener has no filesystem path");
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
alktunnels::producer::TunnelEstablishError::HandlerError(ref m)
|
||||
if m.contains("no filesystem path"),
|
||||
),
|
||||
"unnamed bind maps to the handler error: {err:?}"
|
||||
);
|
||||
|
||||
// Arm 2: the unnamed peer — a socket2 autobind client connects to a
|
||||
// NAMED listener; `accept_pair` must refuse (the forwarded `peer`
|
||||
// param requires an address).
|
||||
let dir = std::env::temp_dir().join(format!("alktunnels-g5-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let listen_path = dir.join("named.sock");
|
||||
let _ = std::fs::remove_file(&listen_path);
|
||||
|
||||
let named =
|
||||
alktunnels::local::bind_unix(&listen_path.to_string_lossy()).expect("bind named listener");
|
||||
assert!(named.local_addr().is_ok(), "the named listener has a path");
|
||||
|
||||
let client = socket2::Socket::new(socket2::Domain::UNIX, socket2::Type::STREAM, None)
|
||||
.expect("socket2 client");
|
||||
client
|
||||
.bind(&socket2::SockAddr::unix("").expect("empty sockaddr (the autobind shape)"))
|
||||
.expect("autobind client bind");
|
||||
client
|
||||
.connect(&socket2::SockAddr::unix(&listen_path).expect("listen sockaddr"))
|
||||
.expect("autobind client dials");
|
||||
|
||||
let err = named
|
||||
.accept_pair()
|
||||
.await
|
||||
.expect_err("an unnamed peer cannot be represented in the forwarded op");
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
alktunnels::producer::TunnelEstablishError::HandlerError(ref m)
|
||||
if m.contains("unnamed peer"),
|
||||
),
|
||||
"unnamed peer maps to the handler error: {err:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&listen_path);
|
||||
}
|
||||
+35
-2
@@ -14,11 +14,11 @@ mod harness;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use alktunnels::params::{Substrate, TunnelParams};
|
||||
use alktunnels::params::{Substrate, SubstrateAddr, TunnelParams};
|
||||
use alktunnels::producer::ResourceRegistry;
|
||||
use alktunnels::TunnelSession;
|
||||
|
||||
use harness::wire_forward;
|
||||
use harness::{wire_direct_forward, wire_forward};
|
||||
|
||||
/// A TCP echo server on an ephemeral port.
|
||||
async fn tcp_echo_server() -> std::net::SocketAddr {
|
||||
@@ -111,6 +111,39 @@ async fn udp_datagram_round_trip_through_the_framed_adapter() {
|
||||
session.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_udp_through_local_dial_round_trips_framed() {
|
||||
// The DIRECT op's DialFn reuse pin (the local-bind-halves task):
|
||||
// `local_dial` is the ONE dial closure the base op and the direct
|
||||
// op share — the UDP wrap (the FRAMED `UdpHalf` adapter, ADR-003)
|
||||
// happens INSIDE the closure (review 001 U-1), so direct-udp gets
|
||||
// datagram-boundary framing free. Driven against a REAL UDP echo
|
||||
// socket: the session sends the codec frame, the direct establisher
|
||||
// dials via `local_dial`, the framed adapter delivers ONE datagram
|
||||
// to the target, and the echo returns through the same path.
|
||||
let addr = udp_echo_server().await;
|
||||
let topo = wire_direct_forward(alktunnels::local::local_dial()).await;
|
||||
|
||||
let mut session = TunnelSession::open_direct(
|
||||
&topo.consumer,
|
||||
SubstrateAddr::HostPort {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: addr.port(),
|
||||
},
|
||||
Substrate::Udp,
|
||||
)
|
||||
.await
|
||||
.expect("udp direct open through local_dial");
|
||||
session.send_datagram(b"direct-udp").await.expect("send");
|
||||
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("io ok")
|
||||
.expect("datagram");
|
||||
assert_eq!(&dg[..], b"direct-udp");
|
||||
session.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn udp_large_datagram_through_bounded_buffers() {
|
||||
// The POC's sizing test: a 1400-byte datagram (max ethernet MTU
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
//! Integration tests for the bind-first establisher + accept
|
||||
//! dispatcher (shape 3 — ADR-008 §1): the producer whose establisher
|
||||
//! BINDS (via the injected `BindFn`), mints the correlation token,
|
||||
//! contributes `bound` + `listen` to the open reply, and whose pump
|
||||
//! handler is the accept dispatcher — per accept it opens
|
||||
//! `channels/tunnel/forwarded` back toward the listen opener and pumps
|
||||
//! the accepted halves against the adopted channel.
|
||||
//!
|
||||
//! Covered (the task's test plan): the full bind-first flow e2e (the
|
||||
//! acceptance gate), the reply-field projection (both fields seen; a
|
||||
//! dial open sees neither), EOF teardown (the idle-stream sentinel
|
||||
//! cancels the loop; the far listener is gone), per-accept failure
|
||||
//! isolation (a rejected forwarded open drops that accept; the loop
|
||||
//! serves the next), the typed establishment errors (bind failure →
|
||||
//! `dial_failed`; registry miss → `unknown_resource`), and the token
|
||||
//! discipline (two opens mint distinct tokens; each dispatcher stamps
|
||||
//! ITS OWN plan's token).
|
||||
|
||||
mod harness;
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
fn init_tracing() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.try_init();
|
||||
}
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::protocol::wire::CallError;
|
||||
use alktunnels::params::{parse_listen_reply, Substrate, SubstrateAddr, TunnelParams};
|
||||
use alktunnels::producer::{
|
||||
BindFirstAcceptLoop, BindFn, BoundListener, ResourceRegistry, TargetHandle,
|
||||
TunnelEstablishError,
|
||||
};
|
||||
|
||||
use harness::{
|
||||
make_forwarded_stub_handler, wire, wire_bindfirst, wire_with, CorrelationMap, RegistrationMode,
|
||||
Topology,
|
||||
};
|
||||
|
||||
fn listen_params(resource: &str) -> TunnelParams {
|
||||
TunnelParams {
|
||||
resource: resource.to_string(),
|
||||
substrate: Substrate::Tcp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Call the producer's open op on channel 0 with the FULL reply
|
||||
/// (the consumer's bind-first surface — the reply projection probe).
|
||||
async fn open_listen_with_reply(
|
||||
topo: &Topology,
|
||||
params: &TunnelParams,
|
||||
) -> Result<(u32, serde_json::Value), CallError> {
|
||||
let payload = serde_json::json!({
|
||||
"operationId": "channels/tunnel/sub",
|
||||
"input": serde_json::json!({
|
||||
"resource": params.resource,
|
||||
"substrate": "tcp",
|
||||
}),
|
||||
});
|
||||
let response = topo.consumer_call.call_with_payload(payload).await;
|
||||
let out = response.result?;
|
||||
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
||||
Ok((channel_id, out))
|
||||
}
|
||||
|
||||
/// The in-process "accepted connection" the test accept loop yields:
|
||||
/// a duplex pair, the far end an echo task (the same shape the dial
|
||||
/// harness echoes with). The peer address is the loop's choice.
|
||||
fn accepted_halves(peer_addr: (u32, u16)) -> (SubstrateAddr, TargetHandle) {
|
||||
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
|
||||
let (mut t_read, mut t_write) = tokio::io::split(target_side);
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match t_read.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => {
|
||||
if t_write.write_all(&buf[..n]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (c_read, c_write) = tokio::io::split(consumer_side);
|
||||
(
|
||||
SubstrateAddr::HostPort {
|
||||
host: peer_addr.0.to_string(),
|
||||
port: peer_addr.1,
|
||||
},
|
||||
TargetHandle {
|
||||
read: Box::new(c_read),
|
||||
write: Box::new(c_write),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The test accept loop's feed point (the test plays the app server):
|
||||
/// push an accepted pair; the dispatcher's loop picks it up.
|
||||
async fn push_accept(
|
||||
queue: &Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
|
||||
notify: &Arc<tokio::sync::Notify>,
|
||||
peer: (u32, u16),
|
||||
) {
|
||||
let (peer_addr, handle) = accepted_halves(peer);
|
||||
queue.lock().await.push_back((peer_addr, handle));
|
||||
notify.notify_waiters();
|
||||
}
|
||||
|
||||
/// The assembly's bind: produces a "listener" (the queue's identity)
|
||||
/// and the OS-chosen bound address (the test's fake — a real
|
||||
/// `bind_tcp` variant lives in the `local` feature).
|
||||
fn test_bind() -> BindFn {
|
||||
Arc::new(|_substrate, _backing| {
|
||||
Box::pin(async {
|
||||
Ok(BoundListener {
|
||||
listener: Arc::new(4242u32) as Arc<dyn std::any::Any + Send + Sync>,
|
||||
bound: SubstrateAddr::HostPort {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 42113,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// A failing bind (the `dial_failed`-class probe — the bind IS the
|
||||
/// dial here).
|
||||
fn failing_bind(message: &'static str) -> BindFn {
|
||||
Arc::new(move |_substrate, _backing| {
|
||||
Box::pin(async move { Err(TunnelEstablishError::DialFailed(message.to_string())) })
|
||||
})
|
||||
}
|
||||
|
||||
fn only_channel_0(topo: &Topology) -> bool {
|
||||
topo.consumer_manager
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0)
|
||||
&& topo
|
||||
.producer
|
||||
.manager()
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0)
|
||||
}
|
||||
|
||||
/// The bind-first topology keeps the LISTEN channel open (the
|
||||
/// dispatcher holds it) — the "no stray channels" probe excludes it
|
||||
/// (and channel 0, the call plane) on both sides.
|
||||
fn no_stray_channels(topo: &Topology, listen_id: u32) -> bool {
|
||||
topo.consumer_manager
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0 || id == listen_id)
|
||||
&& topo
|
||||
.producer
|
||||
.manager()
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0 || id == listen_id)
|
||||
}
|
||||
|
||||
/// The forwarded-op serving stub on the opener (consumer) side +
|
||||
/// its correlation map (the assembly-side bookkeeping the real
|
||||
/// opener-side registration owns).
|
||||
async fn install_forwarded_stub(topo: &Topology) -> CorrelationMap {
|
||||
let correlations: CorrelationMap = Arc::new(tokio::sync::Mutex::new(Default::default()));
|
||||
let core = alkcall::channels::operations::ChannelCore::new(
|
||||
topo.consumer_manager.clone(),
|
||||
alkcall::channels::policy::default_policy(),
|
||||
);
|
||||
core.register_openable(
|
||||
alktunnels::params::tunnel_forwarded_spec(),
|
||||
make_forwarded_stub_handler(Arc::clone(&correlations)),
|
||||
&topo.consumer_registry,
|
||||
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
|
||||
)
|
||||
.expect("register forwarded stub on the opener");
|
||||
correlations
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bindfirst_full_flow_end_to_end() {
|
||||
init_tracing();
|
||||
// THE acceptance gate (ADR-008): opener opens `channels/tunnel/sub`
|
||||
// with the bind-first producer registered → the reply carries
|
||||
// `bound` + `listen` → the opener serves `channels/tunnel/forwarded`
|
||||
// with a correlation map keyed on the token → the test accept loop
|
||||
// accepts → the producer's dispatcher opens forwarded with
|
||||
// `{listen: token, substrate, peer}` → the opener correlates,
|
||||
// pumps against the local BIND client halves → byte round-trip
|
||||
// through the forwarded channel.
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let queue = Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||
let notify = Arc::new(tokio::sync::Notify::new());
|
||||
let topo = wire_bindfirst(
|
||||
registry,
|
||||
test_bind(),
|
||||
harness_queue_loop(queue.clone(), notify.clone()),
|
||||
)
|
||||
.await;
|
||||
|
||||
// The opener opens the listen tunnel (with-reply surface — the
|
||||
// bind-first reply projection).
|
||||
let (channel_id, reply) = open_listen_with_reply(&topo, &listen_params("bind"))
|
||||
.await
|
||||
.expect("listen open");
|
||||
|
||||
// Reply-field projection: `bound` + `listen` ride the success
|
||||
// output alongside the wrapper's `channel_id`.
|
||||
let projected = parse_listen_reply(&reply).expect("projected reply");
|
||||
assert_eq!(
|
||||
projected.bound,
|
||||
Some(SubstrateAddr::HostPort {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 42113,
|
||||
}),
|
||||
"the `bound` field is the OS-chosen (test-chosen) address"
|
||||
);
|
||||
let token = projected.listen.expect("the `listen` token");
|
||||
assert!(token > 0);
|
||||
|
||||
// The opener correlates on the token (the assembly-side map).
|
||||
let correlations = install_forwarded_stub(&topo).await;
|
||||
|
||||
// The LOCAL BIND client (the app-server-side session on the
|
||||
// opener): a raw duplex pair — the map's halves are the stub's
|
||||
// pump target, the far end is the test's client end. The stub's
|
||||
// `pump_bidi` bridges the forwarded channel ↔ these halves, so
|
||||
// bytes the test writes on the far end traverse the whole path
|
||||
// (local BIND client ↔ opener stub pump ↔ forwarded channel ↔
|
||||
// producer-side pump ↔ accepted handle's echo task) and echo back.
|
||||
let (client_read, client_write, l_read, l_write) = bind_client_pair();
|
||||
correlations.lock().await.insert(
|
||||
token,
|
||||
TargetHandle {
|
||||
read: Box::new(l_read),
|
||||
write: Box::new(l_write),
|
||||
},
|
||||
);
|
||||
|
||||
// The opener adopts the LISTEN channel (the idle side — its EOF
|
||||
// is the teardown signal).
|
||||
topo.consumer_manager
|
||||
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
|
||||
.await
|
||||
.expect("adopt listen channel");
|
||||
|
||||
// The test accept loop accepts (the app server dialed).
|
||||
push_accept(&queue, ¬ify, (198, 55231)).await;
|
||||
|
||||
// The dispatcher opens forwarded; the opener's stub handler
|
||||
// correlates by token and pumps against the local halves. The
|
||||
// forwarded channel ID arrives on the opener's manager — find it
|
||||
// (the only non-0, non-listen channel on the opener side after
|
||||
// the adopt + forwarded open settle).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let forwarded_ids: Vec<u32> = topo
|
||||
.consumer_manager
|
||||
.channel_ids()
|
||||
.into_iter()
|
||||
.filter(|&id| id != 0 && id != channel_id)
|
||||
.collect();
|
||||
assert_eq!(forwarded_ids.len(), 1, "one forwarded channel arrived");
|
||||
|
||||
// The round-trip: bytes the test writes on the local BIND
|
||||
// client's end cross the stub's pump → the forwarded channel →
|
||||
// the producer's per-accept pump → the accepted handle's echo →
|
||||
// and back (the full bind-first data path, ADR-008's gate).
|
||||
let (mut client_read, mut client_write) = (client_read, client_write);
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
client_write.write_all(b"bind-ping").await.expect("write");
|
||||
client_write.flush().await.expect("flush");
|
||||
let mut buf = vec![0u8; 9];
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client_read.read_exact(&mut buf),
|
||||
)
|
||||
.await
|
||||
.expect("round trip")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, b"bind-ping");
|
||||
|
||||
// Teardown follows the accepted side: the far echo closes when the
|
||||
// accepted handle's pump sees the test client's EOF. (No channel
|
||||
// assertions here — the listen channel legitimately stays open.)
|
||||
}
|
||||
|
||||
/// The listen-channel EOF teardown: drop the listener session (close
|
||||
/// the listen channel's stream) → the accept loop cancels, the far
|
||||
/// listener is gone (a follow-on accept attempt fails), and the
|
||||
/// forwarded channels already adopted own their teardown
|
||||
/// independently.
|
||||
#[tokio::test]
|
||||
async fn listen_channel_eof_cancels_loop_and_tears_listener_down() {
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let queue = Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||
let notify = Arc::new(tokio::sync::Notify::new());
|
||||
let topo = wire_bindfirst(
|
||||
registry,
|
||||
test_bind(),
|
||||
harness_queue_loop(queue.clone(), notify.clone()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (channel_id, reply) = open_listen_with_reply(&topo, &listen_params("bind"))
|
||||
.await
|
||||
.expect("listen open");
|
||||
let token = parse_listen_reply(&reply)
|
||||
.expect("projected")
|
||||
.listen
|
||||
.expect("token");
|
||||
|
||||
// The opener adopts the listen channel and then DROPS its stream
|
||||
// (the listener session is dropped — the zero-byte EOF sentinel).
|
||||
let (send, _recv) = topo
|
||||
.consumer_manager
|
||||
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
|
||||
.await
|
||||
.expect("adopt listen channel");
|
||||
drop(send);
|
||||
|
||||
// The opener-side teardown reaps the listen channel.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
assert!(
|
||||
!topo.producer.manager().has_channel(channel_id),
|
||||
"the listen channel reaped after the session dropped"
|
||||
);
|
||||
|
||||
// A follow-on accept attempt: the loop is gone — the accept queue
|
||||
// accepts nothing new (the listener was torn down with the plan).
|
||||
// Observable via the queue: nothing pumps it anymore.
|
||||
let _ = token;
|
||||
push_accept(&queue, ¬ify, (198, 55232)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
assert_eq!(
|
||||
queue.lock().await.len(),
|
||||
1,
|
||||
"the accept loop is gone: the pushed accept was never consumed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-accept failure isolation: the opener's forwarded registry
|
||||
/// rejects one forwarded open (scope-less identity) → that accept's
|
||||
/// connection drops, the loop serves the next accept.
|
||||
#[tokio::test]
|
||||
async fn forwarded_open_failure_isolates_the_accept() {
|
||||
init_tracing();
|
||||
// The topology's consumer identity holds `tunnel:open` but NOT
|
||||
// `tunnel:forwarded` — every forwarded open fails FORBIDDEN at the
|
||||
// opener's ACL. The loop must keep serving (a second accept
|
||||
// produces a second forwarded-open attempt).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let queue = Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||
let notify = Arc::new(tokio::sync::Notify::new());
|
||||
let topo = wire_with(
|
||||
registry,
|
||||
RegistrationMode::BindFirst(
|
||||
test_bind(),
|
||||
harness_queue_loop(queue.clone(), notify.clone()),
|
||||
),
|
||||
Some(harness::forwarded_scopeless_identity()),
|
||||
None,
|
||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (listen_id, _reply) = open_listen_with_reply(&topo, &listen_params("bind"))
|
||||
.await
|
||||
.expect("listen open");
|
||||
|
||||
// First accept: the forwarded open fails (scope-less opener) —
|
||||
// the accepted connection drops, the loop continues.
|
||||
push_accept(&queue, ¬ify, (198, 55233)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
assert!(
|
||||
no_stray_channels(&topo, listen_id),
|
||||
"a failed forwarded open leaves no channel beyond the listen channel"
|
||||
);
|
||||
|
||||
// Second accept: the loop is still alive (per-accept isolation) —
|
||||
// this one fails the same way, but the LOOP's health is what the
|
||||
// test pins: the accept was CONSUMED (the queue drained).
|
||||
push_accept(&queue, ¬ify, (198, 55234)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let remaining = queue.lock().await.len();
|
||||
assert_eq!(
|
||||
remaining, 0,
|
||||
"the second accept was consumed too (the loop served it)"
|
||||
);
|
||||
assert!(no_stray_channels(&topo, listen_id));
|
||||
}
|
||||
|
||||
/// Establishment error paths: bind failure → `dial_failed`-class on
|
||||
/// the wire (`channel:open_failed`); registry miss →
|
||||
/// `unknown_resource`.
|
||||
#[tokio::test]
|
||||
async fn establishment_errors_are_typed_on_the_wire() {
|
||||
// Bind failure → dial_failed (the bind IS the dial here).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let topo = wire_bindfirst(
|
||||
registry,
|
||||
failing_bind("address already in use"),
|
||||
noop_loop(),
|
||||
)
|
||||
.await;
|
||||
match open_listen_with_reply(&topo, &listen_params("bind")).await {
|
||||
Ok(_) => panic!("bind failure must fail the open"),
|
||||
Err(e) => {
|
||||
assert_eq!(e.code, "channel:open_failed");
|
||||
let reason = e
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("reason"))
|
||||
.and_then(|r| r.as_str())
|
||||
.expect("reason in details");
|
||||
assert_eq!(reason, "dial_failed");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
assert!(only_channel_0(&topo), "a failed open leaves no channel");
|
||||
|
||||
// Registry miss → unknown_resource.
|
||||
let registry = ResourceRegistry::new();
|
||||
let topo = wire_bindfirst(registry, test_bind(), noop_loop()).await;
|
||||
match open_listen_with_reply(&topo, &listen_params("ghost")).await {
|
||||
Ok(_) => panic!("registry miss must fail the open"),
|
||||
Err(e) => {
|
||||
assert_eq!(e.code, "channel:open_failed");
|
||||
let reason = e
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("reason"))
|
||||
.and_then(|r| r.as_str())
|
||||
.expect("reason in details");
|
||||
assert_eq!(reason, "unknown_resource");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
assert!(only_channel_0(&topo));
|
||||
}
|
||||
|
||||
/// Token discipline: two opens on the same producer mint distinct
|
||||
/// tokens; each dispatcher stamps ITS OWN plan's token (the second
|
||||
/// open's forwarded opens carry the second token — pinned via the
|
||||
/// correlation stub's map: the FIRST token's entry is left
|
||||
/// unconsumed).
|
||||
#[tokio::test]
|
||||
async fn token_discipline_two_opens_distinct_tokens() {
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let queue = Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||
let notify = Arc::new(tokio::sync::Notify::new());
|
||||
let topo = wire_bindfirst(
|
||||
registry,
|
||||
test_bind(),
|
||||
harness_queue_loop(queue.clone(), notify.clone()),
|
||||
)
|
||||
.await;
|
||||
let correlations = install_forwarded_stub(&topo).await;
|
||||
|
||||
let (id1, reply1) = open_listen_with_reply(&topo, &listen_params("bind"))
|
||||
.await
|
||||
.expect("open 1");
|
||||
let (id2, reply2) = open_listen_with_reply(&topo, &listen_params("bind"))
|
||||
.await
|
||||
.expect("open 2");
|
||||
assert_ne!(id1, id2);
|
||||
let t1 = parse_listen_reply(&reply1)
|
||||
.expect("reply 1")
|
||||
.listen
|
||||
.expect("token 1");
|
||||
let t2 = parse_listen_reply(&reply2)
|
||||
.expect("reply 2")
|
||||
.listen
|
||||
.expect("token 2");
|
||||
assert_ne!(t1, t2, "two opens mint distinct tokens");
|
||||
|
||||
// Adopt both listen channels (both sessions live).
|
||||
topo.consumer_manager
|
||||
.adopt_channel(id1, alktunnels::TUNNEL_ALPN, None)
|
||||
.await
|
||||
.expect("adopt listen 1");
|
||||
topo.consumer_manager
|
||||
.adopt_channel(id2, alktunnels::TUNNEL_ALPN, None)
|
||||
.await
|
||||
.expect("adopt listen 2");
|
||||
|
||||
// Both dispatchers are live. Correlate BOTH tokens (each opener
|
||||
// session keys its own), but only register the local halves for
|
||||
// t1 — a forwarded open stamped t2 would correlation-miss. Push
|
||||
// one accept AFTER open 2: whichever dispatcher consumed it, the
|
||||
// STAMPED token is the plan's own. To pin "each dispatcher stamps
|
||||
// ITS OWN plan's token", correlate both but track which token the
|
||||
// forwarded open carried.
|
||||
correlations.lock().await.insert(t1, {
|
||||
let (l_read, l_write, _, _) = bind_client_pair();
|
||||
TargetHandle {
|
||||
read: Box::new(l_read),
|
||||
write: Box::new(l_write),
|
||||
}
|
||||
});
|
||||
correlations.lock().await.insert(t2, {
|
||||
let (l_read, l_write, _, _) = bind_client_pair();
|
||||
TargetHandle {
|
||||
read: Box::new(l_read),
|
||||
write: Box::new(l_write),
|
||||
}
|
||||
});
|
||||
|
||||
push_accept(&queue, ¬ify, (198, 55235)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
// The accept was consumed by exactly one dispatcher, and the
|
||||
// forwarded open succeeded (correlated by its stamped token).
|
||||
assert_eq!(queue.lock().await.len(), 0, "one accept consumed");
|
||||
assert_eq!(
|
||||
correlations.lock().await.len(),
|
||||
1,
|
||||
"one token's halves were consumed"
|
||||
);
|
||||
}
|
||||
|
||||
/// The forwarded-op serving stub's forwarded-substrate log: the
|
||||
/// `substrate` param of every forwarded open (the M-1 remediation
|
||||
/// probe surface — the substrate's SOURCE: the plan, not a re-parse).
|
||||
pub type SubstrateLog = Arc<tokio::sync::Mutex<Vec<alktunnels::params::Substrate>>>;
|
||||
|
||||
/// The forwarded-op serving stub variant that records each forwarded
|
||||
/// open's `substrate` param, then pumps against the correlated halves
|
||||
/// (the same shape as `make_forwarded_stub_handler` + the log).
|
||||
fn make_substrate_recording_stub_handler(
|
||||
correlations: CorrelationMap,
|
||||
substrates: SubstrateLog,
|
||||
) -> alkcall::channels::operations::OpenHandler {
|
||||
Arc::new(
|
||||
move |input: serde_json::Value,
|
||||
_plan: Option<alkcall::channels::operations::ChannelPlan>,
|
||||
conn: alkcall::core::types::Connection,
|
||||
_auth: alkcall::core::auth::AuthContext| {
|
||||
let correlations = Arc::clone(&correlations);
|
||||
let substrates = Arc::clone(&substrates);
|
||||
tokio::spawn(async move {
|
||||
let forwarded: alktunnels::params::TunnelForwardedParams =
|
||||
match serde_json::from_value(input) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "substrate stub: params parse failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
substrates.lock().await.push(forwarded.substrate);
|
||||
let target = correlations.lock().await.remove(&forwarded.listen);
|
||||
let Some(target) = target else {
|
||||
tracing::error!(
|
||||
listen = forwarded.listen,
|
||||
"substrate stub: correlation miss"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(bidi) = conn.accept_bi().await.ok() else {
|
||||
tracing::error!("substrate stub: accept_bi failed");
|
||||
return;
|
||||
};
|
||||
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The forwarded-op serving stub recording substrates, installed on
|
||||
/// the opener (consumer) side (the same post-hoc registration posture
|
||||
/// as `install_forwarded_stub`).
|
||||
async fn install_substrate_recording_stub(
|
||||
topo: &Topology,
|
||||
substrates: SubstrateLog,
|
||||
) -> CorrelationMap {
|
||||
let correlations: CorrelationMap = Arc::new(tokio::sync::Mutex::new(Default::default()));
|
||||
let core = alkcall::channels::operations::ChannelCore::new(
|
||||
topo.consumer_manager.clone(),
|
||||
alkcall::channels::policy::default_policy(),
|
||||
);
|
||||
core.register_openable(
|
||||
alktunnels::params::tunnel_forwarded_spec(),
|
||||
make_substrate_recording_stub_handler(Arc::clone(&correlations), substrates),
|
||||
&topo.consumer_registry,
|
||||
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
|
||||
)
|
||||
.expect("register substrate-recording forwarded stub on the opener");
|
||||
correlations
|
||||
}
|
||||
|
||||
/// M-1 remediation pin: the forwarded opens' `substrate` comes from
|
||||
/// the PLAN (stamped at establisher time), not a re-parse of wire
|
||||
/// input — a UDP bind-first listen produces forwarded opens carrying
|
||||
/// `substrate: udp` even though the re-parse-and-fallback shape would
|
||||
/// have stamped `tcp` if it ever fell back. (The fallback itself is
|
||||
/// REMOVED — this test pins the plan as the source by observing the
|
||||
/// forwarded params through a real dispatcher round trip.)
|
||||
#[tokio::test]
|
||||
async fn forwarded_opens_carry_plan_stamped_substrate() {
|
||||
init_tracing();
|
||||
// A UDP-flavored bind-first registry: the open is
|
||||
// `{resource: "bind", substrate: "udp"}` — the plan must stamp
|
||||
// Udp (the old fallback shape would have stamped Tcp on any
|
||||
// parse failure).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Udp, "assembly-owned")
|
||||
.await;
|
||||
let queue = Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||
let notify = Arc::new(tokio::sync::Notify::new());
|
||||
let topo = wire_bindfirst(
|
||||
registry,
|
||||
test_bind(),
|
||||
harness_queue_loop(queue.clone(), notify.clone()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let substrates: SubstrateLog = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let correlations = install_substrate_recording_stub(&topo, Arc::clone(&substrates)).await;
|
||||
|
||||
// The listen open over the UDP substrate (the harness's
|
||||
// open-call helper hardcodes "tcp" — call with the udp input
|
||||
// directly; the schema gate accepts the registered UDP resource).
|
||||
let payload = serde_json::json!({
|
||||
"operationId": "channels/tunnel/sub",
|
||||
"input": {"resource": "bind", "substrate": "udp"},
|
||||
});
|
||||
let response = topo.consumer_call.call_with_payload(payload).await;
|
||||
let reply = response.result.expect("udp listen open");
|
||||
let listen_id = reply["channel_id"].as_u64().expect("channel_id") as u32;
|
||||
let token = parse_listen_reply(&reply)
|
||||
.expect("projected reply")
|
||||
.listen
|
||||
.expect("the `listen` token");
|
||||
|
||||
// The opener correlates on the token and adopts the listen
|
||||
// channel (the idle side).
|
||||
let (l_read, l_write, _, _) = bind_client_pair();
|
||||
correlations.lock().await.insert(
|
||||
token,
|
||||
TargetHandle {
|
||||
read: Box::new(l_read),
|
||||
write: Box::new(l_write),
|
||||
},
|
||||
);
|
||||
topo.consumer_manager
|
||||
.adopt_channel(listen_id, alktunnels::TUNNEL_ALPN, None)
|
||||
.await
|
||||
.expect("adopt listen channel");
|
||||
|
||||
// One accept: the dispatcher opens forwarded — the forwarded
|
||||
// params' `substrate` must be the PLAN's (Udp), not a fallback.
|
||||
push_accept(&queue, ¬ify, (198, 55236)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
|
||||
let logged = substrates.lock().await.clone();
|
||||
assert_eq!(
|
||||
logged,
|
||||
vec![alktunnels::params::Substrate::Udp],
|
||||
"the forwarded open's substrate is plan-stamped (Udp), not re-parsed"
|
||||
);
|
||||
}
|
||||
|
||||
/// A plain dial open sees NEITHER reply field (the projection
|
||||
/// property — the reserved-key guard upstream guarantees `channel_id`
|
||||
/// stays wrapper-owned; a dial establisher contributes nothing).
|
||||
#[tokio::test]
|
||||
async fn dial_open_reply_has_neither_field() {
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("echo", Substrate::Tcp, "in-process")
|
||||
.await;
|
||||
let topo = wire(registry, harness::echo_dial()).await;
|
||||
let payload = serde_json::json!({
|
||||
"operationId": "channels/tunnel/sub",
|
||||
"input": {"resource": "echo", "substrate": "tcp"},
|
||||
});
|
||||
let response = topo.consumer_call.call_with_payload(payload).await;
|
||||
let out = response.result.expect("dial open");
|
||||
let projected = parse_listen_reply(&out).expect("projected");
|
||||
assert_eq!(
|
||||
projected,
|
||||
alktunnels::params::TunnelListenReply {
|
||||
bound: None,
|
||||
listen: None
|
||||
},
|
||||
"a dial establisher's open carries neither field"
|
||||
);
|
||||
}
|
||||
|
||||
/// The accept loop's queue-shaped stand-in (the harness_queue_loop in
|
||||
/// the tests above): a closure popping from the test's VecDeque +
|
||||
/// Notify pair. (Named separately from the generic helper so the
|
||||
/// tests read: queue in, loop out.)
|
||||
fn harness_queue_loop(
|
||||
queue: Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
|
||||
notify: Arc<tokio::sync::Notify>,
|
||||
) -> BindFirstAcceptLoop {
|
||||
Arc::new(move |_listener| {
|
||||
let queue = Arc::clone(&queue);
|
||||
let notify = Arc::clone(¬ify);
|
||||
Box::pin(async move {
|
||||
// The queue-driven accept: check once, wait for a push,
|
||||
// check again (the Notify lost-wakeup pattern — the
|
||||
// `Notified` future is created BEFORE the state check on
|
||||
// the waiting path). One accepted pair per closure call
|
||||
// (the dispatcher invokes the closure per accept).
|
||||
let popped = {
|
||||
let notified = std::pin::pin!(notify.notified());
|
||||
let mut q = queue.lock().await;
|
||||
if let Some(pair) = q.pop_front() {
|
||||
Some(pair)
|
||||
} else {
|
||||
drop(q);
|
||||
notified.await;
|
||||
let mut q = queue.lock().await;
|
||||
q.pop_front()
|
||||
}
|
||||
};
|
||||
match popped {
|
||||
Some(pair) => Ok(pair),
|
||||
None => Err(TunnelEstablishError::DialFailed(
|
||||
"listener closed".to_string(),
|
||||
)),
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// A never-accepting loop (the establishment-error probes — the loop
|
||||
/// never runs: the open fails at the establisher).
|
||||
fn noop_loop() -> BindFirstAcceptLoop {
|
||||
Arc::new(|_listener| {
|
||||
Box::pin(async { Err(TunnelEstablishError::DialFailed("noop loop".to_string())) })
|
||||
})
|
||||
}
|
||||
|
||||
/// The "local BIND client" pair (the opener-side pump target): a raw
|
||||
/// duplex pair — the map's halves are the stub's pump target, the far
|
||||
/// end is the test's client end (driven directly; the accepted side's
|
||||
/// echo task closes the loop).
|
||||
fn bind_client_pair() -> (
|
||||
tokio::io::ReadHalf<tokio::io::DuplexStream>,
|
||||
tokio::io::WriteHalf<tokio::io::DuplexStream>,
|
||||
tokio::io::ReadHalf<tokio::io::DuplexStream>,
|
||||
tokio::io::WriteHalf<tokio::io::DuplexStream>,
|
||||
) {
|
||||
let (a, b) = tokio::io::duplex(64 * 1024);
|
||||
let (a_read, a_write) = tokio::io::split(a);
|
||||
let (b_read, b_write) = tokio::io::split(b);
|
||||
(b_read, b_write, a_read, a_write)
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
//! Integration tests for the direct op (`channels/tunnel/direct`,
|
||||
//! ADR-007) end to end over the duplex harness: the params → spec →
|
||||
//! establisher → registration → `TunnelSession::open_direct` chain.
|
||||
//!
|
||||
//! Covered: e2e round-trips through `pump_bidi` (tcp + udp — the
|
||||
//! framed adapter wraps at the dial closure, datagram boundary
|
||||
//! preserved), the schema-class failure for malformed targets (no
|
||||
//! phantom session), the `NOT_FOUND` posture on a producer without
|
||||
//! the direct op registered, the structural scope separation
|
||||
//! (`tunnel:open` does NOT imply `tunnel:direct` — FORBIDDEN, and
|
||||
//! vice versa), and the CF-006 per-call opener identity on the
|
||||
//! direct establisher (witness).
|
||||
|
||||
mod harness;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::protocol::wire::CallError;
|
||||
use alktunnels::params::{Substrate, SubstrateAddr};
|
||||
use alktunnels::{TunnelEstablishError, TunnelSession};
|
||||
|
||||
use harness::{
|
||||
all_substrate_dial, consumer_identity, echo_dial, failing_dial, framed_udp_echo_dial, wire,
|
||||
wire_direct, wire_direct_forward, wire_direct_with_identity, Topology,
|
||||
};
|
||||
|
||||
fn target_hostport(host: &str, port: u16) -> SubstrateAddr {
|
||||
SubstrateAddr::HostPort {
|
||||
host: host.to_string(),
|
||||
port,
|
||||
}
|
||||
}
|
||||
|
||||
/// Call the producer's direct op on channel 0 (the raw call surface),
|
||||
/// returning the wire `CallError` on failure.
|
||||
async fn call_direct(
|
||||
topo: &Topology,
|
||||
input: serde_json::Value,
|
||||
) -> Result<serde_json::Value, CallError> {
|
||||
let payload = serde_json::json!({
|
||||
"operationId": "channels/tunnel/direct",
|
||||
"input": input,
|
||||
});
|
||||
let response = topo.consumer_call.call_with_payload(payload).await;
|
||||
response.result
|
||||
}
|
||||
|
||||
/// Adopt the producer-allocated channel and pump it against in-process
|
||||
/// halves (the consumer-side data plane, driven manually).
|
||||
async fn adopt_and_pump(
|
||||
topo: &Topology,
|
||||
channel_id: u32,
|
||||
) -> (
|
||||
tokio::io::ReadHalf<tokio::io::DuplexStream>,
|
||||
tokio::io::WriteHalf<tokio::io::DuplexStream>,
|
||||
tokio::task::JoinHandle<(u64, u64)>,
|
||||
) {
|
||||
let (send, recv) = topo
|
||||
.consumer_manager
|
||||
.adopt_channel(channel_id, "alk/tunnel", None)
|
||||
.await
|
||||
.expect("adopt producer-allocated channel");
|
||||
let bidi = alkcall::core::types::BiStream::from_joined(recv, send);
|
||||
let (channel_end, local_end) = tokio::io::duplex(64 * 1024);
|
||||
let (c_read, c_write) = tokio::io::split(channel_end);
|
||||
let pump = tokio::spawn(alkcall::channels::pump::pump_bidi(bidi, c_read, c_write));
|
||||
let (l_read, l_write) = tokio::io::split(local_end);
|
||||
(l_read, l_write, pump)
|
||||
}
|
||||
|
||||
/// Only channel 0 may remain — a failed open leaves no data channel
|
||||
/// on either side (the phantom-channel property).
|
||||
fn no_data_channels(topo: &Topology) -> bool {
|
||||
let consumer = topo.consumer_manager.channel_ids();
|
||||
let producer = topo.producer.manager().channel_ids();
|
||||
consumer.iter().all(|&id| id == 0) && producer.iter().all(|&id| id == 0)
|
||||
}
|
||||
|
||||
fn establishment_reason_of(err: &CallError) -> Option<&str> {
|
||||
err.details
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("reason"))
|
||||
.and_then(|r| r.as_str())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_tcp_round_trips_through_pump_bidi() {
|
||||
let topo = wire_direct(echo_dial()).await;
|
||||
|
||||
let out = call_direct(
|
||||
&topo,
|
||||
serde_json::json!({
|
||||
"substrate": "tcp",
|
||||
"target": {"host": "db.internal", "port": 5432}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("direct open must succeed");
|
||||
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
||||
assert!(topo.producer.manager().has_channel(channel_id));
|
||||
|
||||
let (mut read, mut write, pump) = adopt_and_pump(&topo, channel_id).await;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
write.write_all(b"ping-direct").await.expect("write");
|
||||
write.flush().await.expect("flush");
|
||||
let mut buf = vec![0u8; 11];
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
|
||||
.await
|
||||
.expect("round trip timed out")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, b"ping-direct");
|
||||
|
||||
// EOF propagates and the wrapper reaps the producer-side channel.
|
||||
drop(write);
|
||||
drop(read);
|
||||
let (c2p, p2c) = tokio::time::timeout(std::time::Duration::from_secs(5), pump)
|
||||
.await
|
||||
.expect("pump completion timed out")
|
||||
.expect("pump task");
|
||||
assert!(c2p > 0 && p2c > 0, "both pumps moved bytes: {c2p}, {p2c}");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert!(
|
||||
!topo.producer.manager().has_channel(channel_id),
|
||||
"wrapper reaped the producer-side channel after pump completion"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_udp_round_trips_framed() {
|
||||
// The UDP dial closure wraps the framed adapter (ADR-003) at the
|
||||
// boundary — the establisher stays substrate-agnostic; the
|
||||
// session's datagram plane sees boundary-preserved frames.
|
||||
let topo = wire_direct(all_substrate_dial()).await;
|
||||
|
||||
let out = call_direct(
|
||||
&topo,
|
||||
serde_json::json!({
|
||||
"substrate": "udp",
|
||||
"target": {"host": "10.0.0.7", "port": 53}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("udp direct open must succeed");
|
||||
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
||||
|
||||
let mut session = TunnelSession::adopt(
|
||||
&topo.consumer_manager,
|
||||
channel_id,
|
||||
Substrate::Udp,
|
||||
"alk/tunnel",
|
||||
)
|
||||
.await
|
||||
.expect("adopt into a udp session");
|
||||
session
|
||||
.send_datagram(b"datagram-direct")
|
||||
.await
|
||||
.expect("send");
|
||||
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
.await
|
||||
.expect("round trip timed out")
|
||||
.expect("recv")
|
||||
.expect("datagram");
|
||||
assert_eq!(dg.as_ref(), b"datagram-direct");
|
||||
session.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_target_is_schema_class_and_leaves_no_channel() {
|
||||
// The registry's input-schema gate runs before the establisher: a
|
||||
// unix target without `path` (and a tcp target without `port`)
|
||||
// fails as `INVALID_INPUT` — never a phantom session, never a
|
||||
// establisher dial.
|
||||
let topo = wire_direct(echo_dial()).await;
|
||||
|
||||
let err = call_direct(
|
||||
&topo,
|
||||
serde_json::json!({
|
||||
"substrate": "unix",
|
||||
"target": {"host": "nope", "port": 1}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("unix target without path must fail the schema gate");
|
||||
assert_eq!(err.code, "INVALID_INPUT");
|
||||
assert!(establishment_reason_of(&err).is_none());
|
||||
assert!(no_data_channels(&topo));
|
||||
|
||||
let err = call_direct(
|
||||
&topo,
|
||||
serde_json::json!({
|
||||
"substrate": "tcp",
|
||||
"target": {"host": "nope"}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("tcp target without port must fail the schema gate");
|
||||
assert_eq!(err.code, "INVALID_INPUT");
|
||||
assert!(no_data_channels(&topo));
|
||||
|
||||
// Missing target entirely.
|
||||
let err = call_direct(&topo, serde_json::json!({"substrate": "tcp"}))
|
||||
.await
|
||||
.expect_err("missing target must fail the schema gate");
|
||||
assert_eq!(err.code, "INVALID_INPUT");
|
||||
assert!(no_data_channels(&topo));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dial_failure_passes_through_typed() {
|
||||
// The dial closure's own typed errors pass through unchanged
|
||||
// (ADR-007 §4's table minus the registry row).
|
||||
let topo = wire_direct(failing_dial("no route to target")).await;
|
||||
|
||||
let err = call_direct(
|
||||
&topo,
|
||||
serde_json::json!({
|
||||
"substrate": "tcp",
|
||||
"target": {"host": "blackhole.internal", "port": 9}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("failed dial must fail the open");
|
||||
assert_eq!(err.code, "channel:open_failed");
|
||||
assert_eq!(establishment_reason_of(&err), Some("dial_failed"));
|
||||
assert!(no_data_channels(&topo));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn producer_without_direct_op_rejects_loudly() {
|
||||
// An old producer (only the base op registered) rejects the
|
||||
// unknown op loudly — NOT_FOUND, the SSH "unknown channel type"
|
||||
// posture.
|
||||
let registry = alktunnels::producer::ResourceRegistry::new();
|
||||
registry
|
||||
.register("echo", Substrate::Tcp, "in-process")
|
||||
.await;
|
||||
let topo = wire(registry, echo_dial()).await;
|
||||
assert!(topo
|
||||
.producer_registry
|
||||
.registration("channels/tunnel/direct")
|
||||
.is_none());
|
||||
|
||||
let err = call_direct(
|
||||
&topo,
|
||||
serde_json::json!({
|
||||
"substrate": "tcp",
|
||||
"target": {"host": "db.internal", "port": 5432}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("unknown op must be rejected loudly");
|
||||
assert_eq!(err.code, "NOT_FOUND");
|
||||
assert!(no_data_channels(&topo));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tunnel_open_scope_does_not_imply_tunnel_direct() {
|
||||
// ADR-006 Amendment 1: the scopes are structurally separate
|
||||
// grants — an identity holding `tunnel:open` (but not
|
||||
// `tunnel:direct`) is FORBIDDEN on the direct op.
|
||||
let topo = wire_direct_with_identity(consumer_identity()).await;
|
||||
|
||||
let err = call_direct(
|
||||
&topo,
|
||||
serde_json::json!({
|
||||
"substrate": "tcp",
|
||||
"target": {"host": "db.internal", "port": 5432}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("tunnel:open must NOT authorize the direct op");
|
||||
assert_eq!(err.code, "FORBIDDEN");
|
||||
assert!(no_data_channels(&topo));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_scope_does_not_imply_tunnel_open() {
|
||||
// The separation is symmetric: `tunnel:direct` does not authorize
|
||||
// the base open op either (two capabilities; a deployment
|
||||
// granting both grants both explicitly). A tunnel:direct-only
|
||||
// identity is FORBIDDEN on `channels/tunnel/sub` even though the
|
||||
// producer registered it.
|
||||
let registry = alktunnels::producer::ResourceRegistry::new();
|
||||
registry
|
||||
.register("echo", Substrate::Tcp, "in-process")
|
||||
.await;
|
||||
let topo = harness::wire_with(
|
||||
registry,
|
||||
harness::RegistrationMode::Dial(echo_dial()),
|
||||
Some(harness::direct_identity()),
|
||||
None,
|
||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||
)
|
||||
.await;
|
||||
let payload = serde_json::json!({
|
||||
"operationId": "channels/tunnel/sub",
|
||||
"input": {"resource": "echo", "substrate": "tcp"},
|
||||
});
|
||||
let response = topo.consumer_call.call_with_payload(payload).await;
|
||||
let err = response
|
||||
.result
|
||||
.expect_err("tunnel:direct must NOT authorize the base op");
|
||||
assert_eq!(err.code, "FORBIDDEN");
|
||||
assert!(no_data_channels(&topo));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_establisher_witnesses_per_call_identity() {
|
||||
// CF-006: the direct establisher's per-call auth carried the END
|
||||
// CALLER's identity (the direct op is the arbitrary-egress
|
||||
// capability — identity visibility matters more, not less).
|
||||
let topo = wire_direct(echo_dial()).await;
|
||||
|
||||
let out = call_direct(
|
||||
&topo,
|
||||
serde_json::json!({
|
||||
"substrate": "tcp",
|
||||
"target": {"host": "db.internal", "port": 5432}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("direct open authorized (both-scopes identity)");
|
||||
assert!(out["channel_id"].is_u64());
|
||||
|
||||
let witness = topo.identity_witness.lock().await.clone();
|
||||
assert_eq!(
|
||||
witness.as_deref(),
|
||||
Some("consumer-both"),
|
||||
"direct establisher saw the per-call opener identity (CF-006)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn establisher_parse_backstop_maps_handler_error() {
|
||||
// The establisher's serde parse is the backstop behind the schema
|
||||
// gate: feed an input the schema would have rejected but a direct
|
||||
// establisher call never schema-checks (the raw-call shape —
|
||||
// assembly variants, or a registry whose schema gate is looser)
|
||||
// and observe the typed `handler_error` mapping (ADR-007 §4).
|
||||
// An unknown substrate value fails the enum parse.
|
||||
let establisher = alktunnels::producer::direct_establisher(echo_dial());
|
||||
let err = establisher(
|
||||
serde_json::json!({
|
||||
"substrate": "sctp",
|
||||
"target": {"host": "h", "port": 1}
|
||||
}),
|
||||
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
|
||||
)
|
||||
.await;
|
||||
match err {
|
||||
Err(alkcall::channels::operations::EstablishmentError::HandlerError { message }) => {
|
||||
assert!(message.contains("invalid direct params"), "{message}");
|
||||
}
|
||||
other => panic!("expected HandlerError backstop, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_establisher_renders_target_to_backing_string() {
|
||||
// The design pin: the establisher renders the target to the SAME
|
||||
// backing-string form the registry backings use and calls the
|
||||
// injected DialFn with it — the dial closure observes the exact
|
||||
// rendered form ("host:port" / path), per ADR-007 §4.
|
||||
let observed: Arc<tokio::sync::Mutex<Vec<(Substrate, String)>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let obs = Arc::clone(&observed);
|
||||
let dial: alktunnels::producer::DialFn =
|
||||
Arc::new(move |substrate: Substrate, backing: &str| {
|
||||
let obs = Arc::clone(&obs);
|
||||
let backing = backing.to_string();
|
||||
Box::pin(async move {
|
||||
obs.lock().await.push((substrate, backing));
|
||||
Err(TunnelEstablishError::DialFailed("stop here".to_string()))
|
||||
})
|
||||
});
|
||||
let establisher = alktunnels::producer::direct_establisher(dial);
|
||||
let auth = alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel");
|
||||
|
||||
let _ = establisher(
|
||||
serde_json::json!({"substrate": "tcp", "target": {"host": "db.internal", "port": 5432}}),
|
||||
auth.clone(),
|
||||
)
|
||||
.await;
|
||||
let _ = establisher(
|
||||
serde_json::json!({"substrate": "unix", "target": {"path": "/run/pg/.s.PGSQL.5432"}}),
|
||||
auth,
|
||||
)
|
||||
.await;
|
||||
|
||||
let seen = observed.lock().await.clone();
|
||||
assert_eq!(
|
||||
seen,
|
||||
vec![
|
||||
(Substrate::Tcp, "db.internal:5432".to_string()),
|
||||
(Substrate::Unix, "/run/pg/.s.PGSQL.5432".to_string()),
|
||||
],
|
||||
"targets rendered to the registry backing-string form"
|
||||
);
|
||||
}
|
||||
|
||||
// --- consumer session level (TunnelSession::open_direct) ---------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_direct_session_round_trip_and_teardown() {
|
||||
let topo = wire_direct_forward(echo_dial()).await;
|
||||
|
||||
let mut session = TunnelSession::open_direct(
|
||||
&topo.consumer,
|
||||
target_hostport("db.internal", 5432),
|
||||
Substrate::Tcp,
|
||||
)
|
||||
.await
|
||||
.expect("open_direct");
|
||||
assert!(topo.producer_manager.has_channel(session.channel_id));
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let (read, write) = session.stream_halves().expect("stream session has halves");
|
||||
write.write_all(b"ping-direct").await.expect("write");
|
||||
write.flush().await.expect("flush");
|
||||
let mut buf = vec![0u8; 11];
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
|
||||
.await
|
||||
.expect("round trip")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, b"ping-direct");
|
||||
|
||||
// Teardown semantics identical to `open` (ADR-007 §5).
|
||||
let channel_id = session.channel_id;
|
||||
let reaped = session.close().await;
|
||||
assert!(reaped, "close reaped the adopted entry");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert!(
|
||||
!topo.producer_manager.has_channel(channel_id),
|
||||
"producer side reaped after pump completion"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_direct_session_datagram_boundary_preserved() {
|
||||
let topo = wire_direct_forward(framed_udp_echo_dial()).await;
|
||||
|
||||
let mut session = TunnelSession::open_direct(
|
||||
&topo.consumer,
|
||||
target_hostport("10.0.0.7", 53),
|
||||
Substrate::Udp,
|
||||
)
|
||||
.await
|
||||
.expect("udp open_direct");
|
||||
session.send_datagram(b"one").await.expect("send");
|
||||
session.send_datagram(b"").await.expect("send empty");
|
||||
let first = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
.await
|
||||
.expect("round trip")
|
||||
.expect("recv")
|
||||
.expect("datagram");
|
||||
assert_eq!(first.as_ref(), b"one");
|
||||
let second = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
.await
|
||||
.expect("round trip")
|
||||
.expect("recv")
|
||||
.expect("datagram");
|
||||
assert!(
|
||||
second.is_empty(),
|
||||
"the empty datagram crosses framed (F-2 layering)"
|
||||
);
|
||||
session.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_direct_malformed_target_no_phantom_session() {
|
||||
// The session-level failure surface: a malformed target is the
|
||||
// schema/invalid_input-class failure (registry-side); a failed
|
||||
// open never yields a session.
|
||||
let topo = wire_direct_forward(echo_dial()).await;
|
||||
|
||||
let err = match TunnelSession::open_direct(
|
||||
&topo.consumer,
|
||||
SubstrateAddr::Path {
|
||||
path: "/nope".to_string(),
|
||||
},
|
||||
Substrate::Tcp,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("path-shaped target on tcp must fail"),
|
||||
};
|
||||
let call = err.open_ref().call_error().expect("wire error");
|
||||
assert_eq!(call.code, "INVALID_INPUT");
|
||||
|
||||
// Both managers hold only channel 0 (the phantom-channel property).
|
||||
assert!(
|
||||
topo.consumer
|
||||
.manager()
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0)
|
||||
&& topo
|
||||
.producer_manager
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0),
|
||||
"no phantom session"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_direct_scope_separated_session_level() {
|
||||
// Session-level scope separation (ADR-006 Amendment 1): a
|
||||
// tunnel:open-only consumer client is FORBIDDEN on open_direct —
|
||||
// the scopes are separate grants, and the session constructor
|
||||
// states which capability it invokes.
|
||||
let topo = harness::wire_direct_forward_with_identity(echo_dial(), consumer_identity()).await;
|
||||
|
||||
let err = match TunnelSession::open_direct(
|
||||
&topo.consumer,
|
||||
target_hostport("db.internal", 5432),
|
||||
Substrate::Tcp,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("tunnel:open-only identity must NOT open_direct"),
|
||||
};
|
||||
let call = err.open_ref().call_error().expect("wire error");
|
||||
assert_eq!(call.code, "FORBIDDEN");
|
||||
assert!(
|
||||
topo.consumer
|
||||
.manager()
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0)
|
||||
&& topo
|
||||
.producer_manager
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0),
|
||||
"no phantom session"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_direct_witness_carries_session_identity() {
|
||||
let topo = wire_direct_forward(echo_dial()).await;
|
||||
let _session = TunnelSession::open_direct(
|
||||
&topo.consumer,
|
||||
target_hostport("db.internal", 5432),
|
||||
Substrate::Tcp,
|
||||
)
|
||||
.await
|
||||
.expect("open_direct");
|
||||
let witness = topo.identity_witness.lock().await.clone();
|
||||
assert_eq!(
|
||||
witness.as_deref(),
|
||||
Some("consumer-both"),
|
||||
"the session path's establisher saw the per-call opener identity"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
//! Integration tests for the forwarded op's SERVING side (the
|
||||
//! listen-opener half — ADR-008 §3, the `tunnels/forwarded-receipt`
|
||||
//! shape): `register_tunnel_forwarded_openable` — the
|
||||
//! `register_openable` shape (no establisher; the data plane starts
|
||||
//! immediately), scope-gated `tunnel:forwarded`, whose handler parses
|
||||
//! `TunnelForwardedParams`, hands the correlation material (parsed
|
||||
//! params + `BiStream`) to the assembly, and awaits the assembly's
|
||||
//! pump inline (R-02).
|
||||
//!
|
||||
//! Covered (the task's test plan): the serving-side e2e (an open with
|
||||
//! valid params + scope → the assembly handler invoked with the parsed
|
||||
//! params + the stream; the assembly pumps against local duplex halves
|
||||
//! → byte round-trip), the scope gate (identity without
|
||||
//! `tunnel:forwarded` → FORBIDDEN, never implied by
|
||||
//! `tunnel:open`/`tunnel:direct`), malformed params (unknown field,
|
||||
//! `listen: 0`, missing `peer` → loud rejection; the serde-gated shape
|
||||
//! tears the channel down at birth — no phantom), and the
|
||||
//! correlation-miss teardown (the assembly's rejection — drop the
|
||||
//! stream, the channel reaps).
|
||||
//!
|
||||
//! The reply reader's unit tests land in `src/params.rs` (this task's
|
||||
//! projection logic, unit-testable independent of the session type);
|
||||
//! the full bind-first flow e2e lives in `tunnels/bindfirst-producer`.
|
||||
//!
|
||||
//! Topology (the harness's producer-leg stub, mirrored): the producer
|
||||
//! leg is the connect side (it holds the `ChannelClient` whose
|
||||
//! `call_open_op` is the per-accept call plane — the reverse POC's
|
||||
//! `open_reverse_channel` shape, reduced); the opener leg is the
|
||||
//! accept side whose install-hook serving registry carries the
|
||||
//! forwarded op under test. The test plays the producer: per probe it
|
||||
//! CALLS `channels/tunnel/forwarded` on the opener's channel-0 call
|
||||
//! plane, adopts the opener-allocated `channel_id` on the
|
||||
//! producer-side manager, and pumps the adopted channel against a
|
||||
//! driving duplex (the dispatcher-pump dance, test-driven). The
|
||||
//! opener-side assembly correlates by the `listen` token and pumps the
|
||||
//! forwarded channel against the local "BIND client" halves — bytes
|
||||
//! the test writes on the client end traverse the whole path and echo
|
||||
//! back.
|
||||
|
||||
mod harness;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::channels::manager::ChannelManager;
|
||||
use alkcall::core::auth::Identity;
|
||||
use alkcall::protocol::wire::CallError;
|
||||
use alktunnels::params::{TUNNEL_FORWARDED_SCOPE, TUNNEL_OPEN_SCOPE};
|
||||
use alktunnels::producer::{register_tunnel_forwarded_openable, TargetHandle};
|
||||
|
||||
use harness::{wire_with, RegistrationMode};
|
||||
|
||||
/// The opener-side identity holding ONLY `tunnel:forwarded` (the
|
||||
/// ingress grant — pinned separate from `tunnel:open`/`tunnel:direct`,
|
||||
/// never implied by either; this identity holds neither).
|
||||
fn forwarded_only_identity() -> Identity {
|
||||
Identity {
|
||||
id: "opener-forwarded-only".to_string(),
|
||||
scopes: vec![TUNNEL_FORWARDED_SCOPE.to_string()],
|
||||
resources: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain dial-egress identity (`tunnel:open` only — the
|
||||
/// never-implied probe's identity).
|
||||
fn open_only_identity() -> Identity {
|
||||
Identity {
|
||||
id: "opener-open-only".to_string(),
|
||||
scopes: vec![TUNNEL_OPEN_SCOPE.to_string()],
|
||||
resources: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the assembly's test handler received per forwarded open (the
|
||||
/// invocation probe): the parsed params as the wrapper handed them
|
||||
/// over.
|
||||
#[derive(Default)]
|
||||
struct Received {
|
||||
params: Option<alktunnels::params::TunnelForwardedParams>,
|
||||
}
|
||||
|
||||
type ReceivedWitness = Arc<tokio::sync::Mutex<Received>>;
|
||||
|
||||
/// Serve the forwarded op on the opener side with the injected
|
||||
/// assembly handler: record the parsed params, pop the correlated
|
||||
/// halves for the token, and `pump_bidi` the channel `BiStream`
|
||||
/// against them (convention 8 — the assembly owns the pump; the
|
||||
/// protocol-side built-in handler only bridges wrapper→assembly). A
|
||||
/// correlation miss (no halves for the token) is the assembly's
|
||||
/// rejection: drop the stream, return — teardown-at-birth reaps the
|
||||
/// forwarded channel.
|
||||
fn serve_forwarded_on(
|
||||
consumer_registry: &Arc<alkcall::registry::registration::OperationRegistry>,
|
||||
manager: ChannelManager,
|
||||
received: ReceivedWitness,
|
||||
local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>>,
|
||||
) {
|
||||
let handler: alktunnels::producer::ForwardedHandler = Arc::new(move |forwarded, bidi| {
|
||||
let received = Arc::clone(&received);
|
||||
let local_halves = Arc::clone(&local_halves);
|
||||
tokio::spawn(async move {
|
||||
*received.lock().await = Received {
|
||||
params: Some(forwarded),
|
||||
};
|
||||
let target = local_halves.lock().await.pop();
|
||||
if let Some(target) = target {
|
||||
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
|
||||
}
|
||||
})
|
||||
});
|
||||
let core = alkcall::channels::operations::ChannelCore::new(
|
||||
manager,
|
||||
alkcall::channels::policy::default_policy(),
|
||||
);
|
||||
register_tunnel_forwarded_openable(
|
||||
&core,
|
||||
consumer_registry,
|
||||
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
|
||||
handler,
|
||||
)
|
||||
.expect("register forwarded openable");
|
||||
}
|
||||
|
||||
/// A topology for the forwarded-op serving probes: the producer leg
|
||||
/// (connect side) serves only the unused base op (failing dial — the
|
||||
/// base op is not what's under test); the opener leg is the accept
|
||||
/// side whose serving registry carries the forwarded op. The test
|
||||
/// plays the producer: `topo.producer.call_open_op(OP_TUNNEL_FORWARDED,
|
||||
/// …)` is the per-accept call; the opener-allocated `channel_id` in
|
||||
/// the reply is what the producer adopts.
|
||||
async fn wire_forwarded_serving(identity: Identity) -> harness::Topology {
|
||||
wire_with(
|
||||
alktunnels::producer::ResourceRegistry::new(),
|
||||
RegistrationMode::Dial(harness::failing_dial("base op unused in forwarded probes")),
|
||||
Some(identity),
|
||||
None,
|
||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn forwarded_input(listen: u32, peer: (u32, u16)) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"listen": listen,
|
||||
"substrate": "tcp",
|
||||
"peer": {"host": peer.0.to_string(), "port": peer.1},
|
||||
})
|
||||
}
|
||||
|
||||
/// Call the forwarded op on the producer's per-accept call plane
|
||||
/// (`call_open_op` — the producer-side initiator's surface; the
|
||||
/// opener's wrapper allocates the channel ID, the producer adopts).
|
||||
async fn call_forwarded(
|
||||
producer: &alkcall::channels::client::ChannelClient,
|
||||
input: serde_json::Value,
|
||||
) -> Result<serde_json::Value, CallError> {
|
||||
producer
|
||||
.call_open_op(alktunnels::params::OP_TUNNEL_FORWARDED, input)
|
||||
.await
|
||||
.result
|
||||
}
|
||||
|
||||
/// The accepted-side echo pair (the producer-side pump target): a
|
||||
/// duplex pair — the driving end an echo task (the app server's
|
||||
/// socket stand-in), the far end the pump's halves.
|
||||
fn driving_halves() -> TargetHandle {
|
||||
let (drive_end, target_end) = tokio::io::duplex(64 * 1024);
|
||||
let (mut t_read, mut t_write) = tokio::io::split(target_end);
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match t_read.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => {
|
||||
if t_write.write_all(&buf[..n]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (d_read, d_write) = tokio::io::split(drive_end);
|
||||
TargetHandle {
|
||||
read: Box::new(d_read),
|
||||
write: Box::new(d_write),
|
||||
}
|
||||
}
|
||||
|
||||
/// The "local BIND client" pair (the opener-side pump target): a raw
|
||||
/// duplex pair — the map's halves are the assembly pump's target, the
|
||||
/// far end is the test's client end (driven directly).
|
||||
fn bind_client_pair() -> (
|
||||
tokio::io::ReadHalf<tokio::io::DuplexStream>,
|
||||
tokio::io::WriteHalf<tokio::io::DuplexStream>,
|
||||
TargetHandle,
|
||||
) {
|
||||
let (a, b) = tokio::io::duplex(64 * 1024);
|
||||
let (a_read, a_write) = tokio::io::split(a);
|
||||
let (b_read, b_write) = tokio::io::split(b);
|
||||
(
|
||||
b_read,
|
||||
b_write,
|
||||
TargetHandle {
|
||||
read: Box::new(a_read),
|
||||
write: Box::new(a_write),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// THE serving-half e2e (this task's gate): an open with valid params
|
||||
/// + scope → the assembly handler invoked with the parsed params +
|
||||
/// the stream → the assembly pumps against local duplex halves → byte
|
||||
/// round-trip through the forwarded channel. (The full bind-first
|
||||
/// flow e2e lives in `tunnels/bindfirst-producer`; this task's e2e is
|
||||
/// the serving half standalone.)
|
||||
#[tokio::test]
|
||||
async fn forwarded_serving_round_trip_end_to_end() {
|
||||
let topo = wire_forwarded_serving(forwarded_only_identity()).await;
|
||||
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
|
||||
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
serve_forwarded_on(
|
||||
&topo.consumer_registry,
|
||||
topo.consumer_manager.clone(),
|
||||
Arc::clone(&received),
|
||||
Arc::clone(&local_halves),
|
||||
);
|
||||
|
||||
// Correlate FIRST (the waiting listener session's bookkeeping —
|
||||
// the assembly pre-registered the local halves for the token
|
||||
// before the forwarded open arrives; a late push is the
|
||||
// correlation-miss shape, probed separately below).
|
||||
const TOKEN: u32 = 918273;
|
||||
let (mut b_read, mut b_write, local) = bind_client_pair();
|
||||
local_halves.lock().await.push(local);
|
||||
|
||||
// The producer calls the forwarded op (valid correlation token +
|
||||
// substrate-shaped peer).
|
||||
let out = call_forwarded(&topo.producer, forwarded_input(TOKEN, (198, 55231)))
|
||||
.await
|
||||
.expect("forwarded open");
|
||||
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
||||
assert!(channel_id > 0);
|
||||
|
||||
// The wrapper allocated the channel on the opener's manager (the
|
||||
// serving side allocates; the producer adopts) and the assembly
|
||||
// pump is live against the correlated halves.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
assert!(
|
||||
topo.consumer_manager.has_channel(channel_id),
|
||||
"the forwarded channel is live on the opener side"
|
||||
);
|
||||
|
||||
// The assembly's test handler saw the parsed params verbatim.
|
||||
let got = received.lock().await.params.clone().expect("invoked");
|
||||
assert_eq!(got.listen, TOKEN, "the correlation token rides `listen`");
|
||||
assert_eq!(got.substrate, alktunnels::params::Substrate::Tcp);
|
||||
assert_eq!(
|
||||
got.peer,
|
||||
alktunnels::params::SubstrateAddr::HostPort {
|
||||
host: "198".to_string(),
|
||||
port: 55231,
|
||||
}
|
||||
);
|
||||
|
||||
// The producer-side adopted pump (the test drives the accepted
|
||||
// side's echo): adopt the opener-allocated ID on the
|
||||
// producer-side manager and pump the adopted channel against a
|
||||
// driving duplex — the dispatcher-pump dance, test-driven.
|
||||
let (send, recv) = topo
|
||||
.producer
|
||||
.manager()
|
||||
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
|
||||
.await
|
||||
.expect("producer adopts the opener-allocated ID");
|
||||
let driving = driving_halves();
|
||||
let _producer_pump = tokio::spawn(alkcall::channels::pump::pump_bidi(
|
||||
alkcall::core::types::BiStream::from_joined(recv, send),
|
||||
driving.read,
|
||||
driving.write,
|
||||
));
|
||||
|
||||
// THE round-trip: bytes the test writes on the local BIND client's
|
||||
// end cross the assembly pump → the forwarded channel → the
|
||||
// producer-side adopted pump → the driving echo → and back (the
|
||||
// forwarded channel's data path, ADR-008 §3's serving half).
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
b_write.write_all(b"forwarded-ping").await.expect("write");
|
||||
b_write.flush().await.expect("flush");
|
||||
let mut buf = vec![0u8; 14];
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
b_read.read_exact(&mut buf),
|
||||
)
|
||||
.await
|
||||
.expect("round trip")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, b"forwarded-ping");
|
||||
}
|
||||
|
||||
/// The scope gate: an identity WITHOUT `tunnel:forwarded` is FORBIDDEN
|
||||
/// — never implied by `tunnel:open` (this identity holds exactly that
|
||||
/// grant; ADR-006 Amendment 1's separation).
|
||||
#[tokio::test]
|
||||
async fn forwarded_scope_gate_is_independent_grant() {
|
||||
let topo = wire_forwarded_serving(open_only_identity()).await;
|
||||
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
|
||||
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
serve_forwarded_on(
|
||||
&topo.consumer_registry,
|
||||
topo.consumer_manager.clone(),
|
||||
Arc::clone(&received),
|
||||
local_halves,
|
||||
);
|
||||
|
||||
match call_forwarded(&topo.producer, forwarded_input(7, (10, 1))).await {
|
||||
Ok(_) => panic!("a scope-less opener must not receive forwarded opens"),
|
||||
Err(e) => {
|
||||
assert_eq!(
|
||||
e.code, "FORBIDDEN",
|
||||
"missing `tunnel:forwarded` → FORBIDDEN"
|
||||
);
|
||||
assert!(
|
||||
e.message.contains("tunnel:forwarded"),
|
||||
"the denial names the missing scope: {}",
|
||||
e.message
|
||||
);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
// No channel was ever allocated (the ACL runs before the wrapper).
|
||||
assert!(
|
||||
topo.consumer_manager
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0),
|
||||
"a FORBIDDEN open leaves no channel on the opener side"
|
||||
);
|
||||
// The assembly handler never ran (no invocation witnessed).
|
||||
assert!(received.lock().await.params.is_none());
|
||||
}
|
||||
|
||||
/// Malformed params: `listen: 0` and a missing `peer` fail the
|
||||
/// registry's schema gate LOUD (`INVALID_INPUT`, before the wrapper —
|
||||
/// no channel ever); an unknown top-level field passes the schema (the
|
||||
/// additive posture) but fails the serde parse (the loud backstop) →
|
||||
/// the wrapper logs + returns early → the channel tears down at birth
|
||||
/// (no phantom channel).
|
||||
#[tokio::test]
|
||||
async fn forwarded_malformed_params_tear_down_loud() {
|
||||
let topo = wire_forwarded_serving(forwarded_only_identity()).await;
|
||||
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
|
||||
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
serve_forwarded_on(
|
||||
&topo.consumer_registry,
|
||||
topo.consumer_manager.clone(),
|
||||
Arc::clone(&received),
|
||||
local_halves,
|
||||
);
|
||||
|
||||
// `listen: 0` — the schema gate (minimum: 1) fires first.
|
||||
match call_forwarded(
|
||||
&topo.producer,
|
||||
serde_json::json!({
|
||||
"listen": 0,
|
||||
"substrate": "tcp",
|
||||
"peer": {"host": "10", "port": 53},
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("`listen: 0` must fail the open"),
|
||||
Err(e) => {
|
||||
assert_eq!(e.code, "INVALID_INPUT", "schema minimum 1 gate");
|
||||
}
|
||||
}
|
||||
|
||||
// Missing `peer` — the schema gate again (all three required).
|
||||
match call_forwarded(
|
||||
&topo.producer,
|
||||
serde_json::json!({"listen": 7, "substrate": "tcp"}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("missing `peer` must fail the open"),
|
||||
Err(e) => {
|
||||
assert_eq!(e.code, "INVALID_INPUT");
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown top-level field — passes the schema (additive posture),
|
||||
// fails the serde parse (deny-unknown backstop) → the wrapper
|
||||
// returns early → teardown-at-birth (the channel was allocated —
|
||||
// the reply IS the channel_id — but reaps promptly).
|
||||
let out = call_forwarded(
|
||||
&topo.producer,
|
||||
serde_json::json!({
|
||||
"listen": 918273,
|
||||
"substrate": "tcp",
|
||||
"peer": {"host": "198", "port": 55232},
|
||||
"sneaky": true
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("the malformed open rides the no-establisher reply shape");
|
||||
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
assert!(
|
||||
!topo.consumer_manager.has_channel(channel_id),
|
||||
"a malformed-params open tears the channel down at birth (no phantom)"
|
||||
);
|
||||
// The assembly handler never ran (the parse gate is protocol-side).
|
||||
assert!(received.lock().await.params.is_none());
|
||||
}
|
||||
|
||||
/// The correlation-miss teardown (the assembly's rejection — it holds
|
||||
/// the map): a valid-shaped open whose `listen` token matches no
|
||||
/// waiting session → the assembly's error path drops the stream and
|
||||
/// returns → teardown-at-birth reaps the forwarded channel. The
|
||||
/// producer's accept loop drops the accepted connection on the open's
|
||||
/// failure (the shapes mirror — pinned producer-side in
|
||||
/// `tunnels/bindfirst-producer`).
|
||||
#[tokio::test]
|
||||
async fn forwarded_correlation_miss_tears_channel_down() {
|
||||
let topo = wire_forwarded_serving(forwarded_only_identity()).await;
|
||||
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
|
||||
// NO correlated halves for the token — the map is empty.
|
||||
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
serve_forwarded_on(
|
||||
&topo.consumer_registry,
|
||||
topo.consumer_manager.clone(),
|
||||
Arc::clone(&received),
|
||||
local_halves,
|
||||
);
|
||||
|
||||
let out = call_forwarded(&topo.producer, forwarded_input(42113, (198, 55233)))
|
||||
.await
|
||||
.expect("the open itself resolves (allocation is wrapper-side)");
|
||||
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
assert!(
|
||||
!topo.consumer_manager.has_channel(channel_id),
|
||||
"a correlation miss tears the forwarded channel down (teardown-at-birth)"
|
||||
);
|
||||
// The assembly handler RAN (the invocation was witnessed — the
|
||||
// rejection is the assembly's) and its map missed.
|
||||
let witnessed = received.lock().await.params.clone();
|
||||
assert_eq!(witnessed.expect("invoked").listen, 42113);
|
||||
}
|
||||
@@ -384,6 +384,44 @@ async fn accept_loop_stops_feeding_after_close_and_maps_the_drop() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The unix accept loop's queue-closed mapping (G-4 — the unix parity
|
||||
/// twin of the TCP test above): the same loop shape over
|
||||
/// `UnixListenerHalves` — a `push` after close errors the loop with
|
||||
/// the handler error, the loop exits on the mapped error, and the
|
||||
/// unaccepted far end is dropped.
|
||||
#[tokio::test]
|
||||
#[cfg(feature = "local")]
|
||||
async fn unix_accept_loop_stops_feeding_after_close_and_maps_the_drop() {
|
||||
let queue = AcceptQueue::new();
|
||||
queue.close().await;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("alktunnels-unixaccept-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("accept-loop.sock");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listen_str = path.to_string_lossy().into_owned();
|
||||
|
||||
let listener = alktunnels::local::bind_unix(&listen_str).expect("bind");
|
||||
let loop_queue = queue.clone();
|
||||
let outcome = tokio::spawn(async move { listener.accept_loop(loop_queue).await });
|
||||
let _ = tokio::net::UnixStream::connect(&path)
|
||||
.await
|
||||
.expect("connect");
|
||||
let err = tokio::time::timeout(std::time::Duration::from_secs(5), outcome)
|
||||
.await
|
||||
.expect("accept loop timed out (did it keep feeding a closed queue?)")
|
||||
.expect("accept loop task")
|
||||
.expect_err("closed queue must end the accept loop");
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
alktunnels::producer::TunnelEstablishError::HandlerError(_)
|
||||
),
|
||||
"queue-closed push maps to the handler error, got {err:?}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// The lost-wakeup regression (review 001 C-1): a `push` landing in
|
||||
/// the wall-clock window between a parked `pop`'s state check and its
|
||||
/// `Notified` registration must still wake the pop. The window only
|
||||
|
||||
Reference in new issue
Block a user