Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1365272146 | ||
|
|
05b25465aa | ||
|
|
e3c6f6c9f8 | ||
|
|
57285d23bd | ||
|
|
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
+257
-1
@@ -4,6 +4,259 @@ 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.3.0] - 2026-10-14
|
||||
|
||||
The dependency-major release: the alkcall substrate bumped 0.8.1 →
|
||||
0.9.0 (the stub-sweep release). No alktunnels API change, no wire
|
||||
change; the only code contact with the 0.9.0 breaking surface was
|
||||
test-side (`Establishment` no longer derives `Debug` — three
|
||||
`expect_err`/`panic!` sites restructured), which is why this is a
|
||||
minor bump rather than a patch: the semver-incompatible dependency
|
||||
bump plus this crate's public re-exports of alkcall types make it
|
||||
semver-visible to downstream consumers (matching the 0.7.1 → 0.8.0
|
||||
ride on 0.2.0).
|
||||
|
||||
### Changed
|
||||
|
||||
- **alkcall 0.8.1 → 0.9.0** (crates.io). The stub-sweep release: the
|
||||
marked-pub open contract (S-01), `channel/control` (S-02),
|
||||
`channel/resources/subscribe` (S-03), and `op/register`
|
||||
forward-through (S-04) replace the four always-failing stubs.
|
||||
None of the four stubs were on this crate's paths (no marked-pub
|
||||
registration, no control-handle subscription, no resource
|
||||
enumerator use, no generic `op/register` announcements), so no
|
||||
behavior change here. Of the 0.9.0 breaking list,
|
||||
`Establishment` dropping its `Debug` derive was the only surface
|
||||
this crate touched — two `bindfirst_establisher` unit tests'
|
||||
`expect_err` calls and one integration test's untyped
|
||||
`panic!("{other:?}")` fall-through restructured (the error type
|
||||
itself remains `Debug`; the `Ok` arm now formats via `panic!("got
|
||||
Ok")`). `ManagerError::InvalidAdopt` (additive),
|
||||
`RelayError::PubTypedOpen` removal, and the
|
||||
`register_imported`/`register_imported_all` return change are
|
||||
untouched by this crate's API use. 136 tests pass; wasm-clean
|
||||
verified.
|
||||
|
||||
## [0.2.1] - 2026-09-28
|
||||
|
||||
The hardening release: fuzzing adopted from the alkcall pattern
|
||||
(`fuzz/` workspace, excluded from the published package) and the
|
||||
alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change.
|
||||
|
||||
### Added
|
||||
|
||||
- **Fuzzing (`fuzz/` workspace, `docs/research/fuzzing.md`)** — three
|
||||
cargo-fuzz targets over this crate's own attacker-reachable surfaces
|
||||
(the envelope/chunk/manager layer is upstream and fuzzed there per
|
||||
alkcall 0.8.1): `datagram_codec` (the ADR-003 codec: no-panic,
|
||||
infallible decode, size bound, F-2 sentinel layering, re-frame
|
||||
round-trip, retained-bytes bound), `params_parse` (the serde
|
||||
params/reply parsers: no-panic on raw bytes and bounded structured
|
||||
values, clean typed failure, round-trips), and `session_pumps` (the
|
||||
wave-2 stateful target: op sequences over a live channels stack per
|
||||
exec, the `TunnelSession` data planes mirrored, no-wedge +
|
||||
shutdown-on-completion + lossless p2c + EOF-sentinel teardown + the
|
||||
`pending` backlog bound asserted). Invariant logic lives in
|
||||
`fuzz/shared/` — a stable-toolchain crate replayed as plain
|
||||
`cargo test`, so the committed corpora (96 + 8 seeds, plus the
|
||||
campaign-promoted `seed-900`) stay executable without nightly.
|
||||
`fuzz/` is excluded from the workspace and from the published
|
||||
package. Campaigns (detached, fork mode): ~25M+228k execs across
|
||||
waves 1–2, zero crashes/hangs/OOMs; all findings were
|
||||
harness-model defects, no crate bugs.
|
||||
|
||||
- **`TunnelSession::datagram_backlog()`** — the UDP `pending` count
|
||||
(the datagrams decoded from a chunk beyond the one returned), the
|
||||
wave-2 harness's asserted backlog bound made caller-visible;
|
||||
`None` for stream sessions or after the halves were taken.
|
||||
|
||||
- **`DatagramReader::retained_bytes()`** — the decoder-state byte
|
||||
count (leftover buffer + the in-flight partial datagram); post-feed
|
||||
bounded by one frame (≤ `MAX_DATAGRAM_LEN` + 2), pinned by the
|
||||
`datagram_codec` target.
|
||||
|
||||
### Changed
|
||||
|
||||
- **alkcall 0.8.0 → 0.8.1** (crates.io). The bug-fix release: a
|
||||
duplicate `adopt_channel`/`open_channel` no longer destroys the
|
||||
live channel it collided with (found by alkcall's own
|
||||
`manager_routing` fuzz target) — directly relevant to this crate's
|
||||
reverse-path adopt flow. No alktunnels API or wire change in this
|
||||
bump; the full suite passes unchanged.
|
||||
|
||||
## [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 +301,8 @@ 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.3.0...HEAD
|
||||
[0.3.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.2.1...v0.3.0
|
||||
[0.2.1]: https://git.alk.dev/alkdev/alktunnels/compare/v0.2.0...v0.2.1
|
||||
[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.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac26db919a1db9d72910ead8741af683bedefc625631028194b5f5e1dbd63cb6"
|
||||
checksum = "a3562f1f7b348cdeb3d9181eaf57b0a2fb3f988d4280e8e225dadfd7f763cb84"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alktunnels"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"alkcall",
|
||||
"bytes",
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alktunnels"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -9,7 +9,11 @@ repository = "https://git.alk.dev/alkdev/alktunnels"
|
||||
readme = "README.md"
|
||||
keywords = ["tunnel", "proxy", "channels", "alkcall", "network"]
|
||||
categories = ["network-programming", "asynchronous"]
|
||||
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/research/", "docs/plans/", "tasks/", "docs/sdd_process.md"]
|
||||
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/research/", "docs/plans/", "tasks/", "docs/sdd_process.md", "fuzz/"]
|
||||
|
||||
[workspace]
|
||||
members = ["."]
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[lib]
|
||||
name = "alktunnels"
|
||||
@@ -19,7 +23,7 @@ default = []
|
||||
local = ["tokio/net"]
|
||||
|
||||
[dependencies]
|
||||
alkcall = "0.7.1"
|
||||
alkcall = "0.9.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,45 @@ 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).
|
||||
|
||||
0.2.1 is the hardening release: the `fuzz/` workspace (three
|
||||
cargo-fuzz targets over the datagram codec, the params parsers, and a
|
||||
stateful session-pump target; corpora replay as plain `cargo test`) —
|
||||
`docs/research/fuzzing.md` — and the alkcall bump 0.8.0 → 0.8.1
|
||||
(duplicate adopt/open no longer destroys the live channel — relevant
|
||||
to the reverse-path adopt flow). Two additive diagnostics:
|
||||
`TunnelSession::datagram_backlog()` and
|
||||
`DatagramReader::retained_bytes()`. No API break, no wire change.
|
||||
|
||||
0.3.0 rides the alkcall bump 0.8.1 → 0.9.0 (the stub-sweep release —
|
||||
the four registered-but-always-failing stubs became real
|
||||
implementations; none were on this crate's paths). No alktunnels API
|
||||
change, no wire change; minor rather than patch because the
|
||||
semver-incompatible dependency bump + this crate's alkcall type
|
||||
re-exports make it semver-visible downstream.
|
||||
|
||||
## 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.9.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,587 @@
|
||||
# Fuzzing alktunnels: Proposal and Plan
|
||||
|
||||
**Status:** adopted — wave-1 complete (`datagram_codec` +
|
||||
`params_parse` implemented, seeds committed, two 10-min campaigns
|
||||
clean, two harness-model findings fixed — see §7); wave-2
|
||||
(`session_pumps`) implemented and campaigning — see §3.3/§7.
|
||||
Campaigns run via the detached runner (§6) — **never as a foreground
|
||||
child of an agent session** (§5).
|
||||
**Date:** 2026-09-28
|
||||
**Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md`
|
||||
(0.8.0 — 0.8.1 campaign; one real finding: duplicate adopt/open destroying
|
||||
the live channel, fixed in 0.8.1). Tooling, corpus policy, runner, and the
|
||||
no-hosted-CI posture are inherited from that doc wholesale — this doc does
|
||||
not re-litigate them; it scopes them to this crate.
|
||||
|
||||
---
|
||||
|
||||
## 1. Should this crate be fuzzed?
|
||||
|
||||
**Yes — but scoped smaller than alkcall's.** The decision follows from
|
||||
three lines, all pointing the same way:
|
||||
|
||||
1. **alkcall's fuzz doc already assigned this to us.** Its §5
|
||||
"non-surfaces (deliberately)" note ends: *the channels layer carries
|
||||
opaque `Bytes` and the handler owns sub-stream framing (ADR-035) —
|
||||
those downstream parsers are downstream crates' fuzz targets.* Our
|
||||
`wire.rs` datagram codec IS that named downstream target. Fuzzing it
|
||||
completes alkcall's fuzz scope rather than duplicating it.
|
||||
|
||||
2. **We own one hand-rolled parse surface of exactly the covered class.**
|
||||
`DatagramReader::feed` (`src/wire.rs:100`) is an incremental
|
||||
length-prefix state machine reassembling `[len: u16 BE][payload]`
|
||||
frames across arbitrary chunk boundaries — length arithmetic and
|
||||
boundary handling, the dominant rust-fuzz trophy-case classes. It is
|
||||
sync, pure, allocation-light, and infallible **by contract** (the
|
||||
decode direction cannot fail; oversize is rejected at frame time) —
|
||||
a fuzz target is how that contract is *demonstrated* rather than
|
||||
believed.
|
||||
|
||||
3. **The stateful DoS class applies to us directly.** One pre-fuzz
|
||||
code-review candidate, the analog of alkcall §6.2-1 (the
|
||||
early-arrival key-space): `DatagramHalves.pending`
|
||||
(`src/consumer.rs:78`) queues every datagram decoded from a chunk
|
||||
beyond the one returned to the caller. A single 16 MiB chunk of
|
||||
`[00 00]` pairs = ~8.4 M empty datagrams → ~8.4 M `VecDeque`
|
||||
entries (≈ 400+ MiB) from ONE peer chunk — a ~25–50× memory
|
||||
amplification riding a bounded channel buffer. Example-based tests
|
||||
structurally cannot find this; a byte-sequence target with a
|
||||
pending-bytes assertion will.
|
||||
|
||||
**The honest caveat** (mirroring alkcall §1): the highest-value alkcall
|
||||
targets — envelope framing, chunk header, demux, manager — are upstream
|
||||
and now fuzzed (0.8.1). Our params parsing is serde + registry-side
|
||||
jsonschema (upstream's `spec_parse` target covers attacker-shaped
|
||||
schemas compiling at registration). Expected yield here: datagram-codec
|
||||
pathologies, the pending-queue bound (§1.3), serde/untagged-enum edge
|
||||
cases in params, and lifecycle invariants in the two-pump shape. That
|
||||
is worthwhile, not five-targets-worth of new machinery on day one —
|
||||
hence the two-wave scope in §4. If the first campaigns find nothing,
|
||||
that is the successful-negative-result posture alkcall's §1 codified:
|
||||
"we think the codec is robust" becomes a demonstrated property, and the
|
||||
targets become regression protection for a wire-stable format
|
||||
(ADR-003).
|
||||
|
||||
## 2. Toolchain (inherited, not re-decided)
|
||||
|
||||
Identical to alkcall §4: **cargo-fuzz 0.13.2 + `arbitrary` 1.4.x**,
|
||||
nightly confined to the `fuzz/` subtree via `fuzz/rust-toolchain.toml`,
|
||||
the crate itself stays stable at MSRV 1.88. AFL optional/deferred;
|
||||
bolero deferred (same async-friction argument); libafl/honggfuzz/OSS-Fuzz
|
||||
not adopted. Sanitizer: `--sanitizer=address` with debug assertions
|
||||
(cargo-fuzz defaults) — no MSAN (no `unsafe` in this crate).
|
||||
|
||||
**Layout (the alkcall pattern, load-bearing):**
|
||||
|
||||
```
|
||||
fuzz/
|
||||
├── Cargo.toml alktunnels-fuzz (nightly-only bins; own [workspace])
|
||||
├── rust-toolchain.toml pins nightly for this subtree only
|
||||
├── fuzz_targets/ thin fuzz_target! wrappers (3-line each)
|
||||
│ ├── datagram_codec.rs
|
||||
│ ├── params_parse.rs
|
||||
│ └── session_pumps.rs
|
||||
├── shared/ alktunnels-fuzz-shared — STABLE-toolchain library
|
||||
│ └── src/… invariant logic + corpus replay (§3)
|
||||
├── corpus/<target>/ committed hand-made seeds (§3)
|
||||
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
|
||||
├── run-detached.sh §6 detached runner (CWD-independent)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
- **Invariant logic lives in `fuzz/shared/`**, not inline in the target
|
||||
binaries — the stable-toolchain corpus replay
|
||||
(`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME
|
||||
invariant functions the fuzzer runs, on stable, in the release
|
||||
verification checklist. This is CI tier-3's deliverable (alkcall
|
||||
§7.2) minus automation; per alkcall §7.9 this repo has no hosted CI
|
||||
and will not get one (same gitea posture) — corpus replay as plain
|
||||
`cargo test` is the fuzz gate.
|
||||
- **Root `Cargo.toml` needs the explicit `[workspace]` table**
|
||||
(`members = ["."]`, `exclude = ["fuzz"]`) and `"fuzz/"` on the
|
||||
publish exclude list — without it cargo auto-discovers `fuzz/shared/`
|
||||
into the main workspace and the MSRV toolchain tries to build the
|
||||
nightly-consumed dev-deps (the exact failure alkcall hit).
|
||||
- **Corpus policy:** commit hand-made seeds, gitignore grown corpora
|
||||
(quinn/h2 model). Seeds per target: valid frames, truncation at every
|
||||
short prefix length, `len = 0` / `len = MAX` / `len = MAX+1`, empty
|
||||
datagrams, batching (two frames per chunk), splitting (one frame per
|
||||
many chunks), and for params: every substrate value, unknown
|
||||
substrate, unknown/missing fields, `listen: 0`, malformed reply
|
||||
shapes, untagged cross-shape targets, deep/invalid JSON.
|
||||
|
||||
## 3. Invariants by target
|
||||
|
||||
### 3.1 `datagram_codec` (wave 1)
|
||||
|
||||
Input: raw `&[u8]` (and in the shared logic, chunk-slice parameter
|
||||
arbitrary). Drives `DatagramReader::feed` + `frame_datagram`
|
||||
(`src/wire.rs`). Invariants:
|
||||
|
||||
- **no-panic on any byte sequence** (the baseline; the one
|
||||
`unreachable!` in `feed` is a structural invariant of the state
|
||||
machine — the target is how it stays true).
|
||||
- **infallibility**: `feed` never errors (contract — oversize is
|
||||
rejected at frame time; the decode direction is infallible by
|
||||
design, `src/wire.rs` docs).
|
||||
- **datagram-size bound**: every yielded datagram is
|
||||
`<= MAX_DATAGRAM_LEN` (u16 frame field makes this structural — the
|
||||
target pins it against future refactors).
|
||||
- **no partial yields**: a datagram completes only when exactly
|
||||
`declared` payload bytes are buffered (`is_mid_datagram` observable
|
||||
consistency); `feed(&[])` never yields anything and never clears
|
||||
mid-datagram state.
|
||||
- **round-trip**: `feed(frame_datagram(x)) == [x]` for any x; the
|
||||
reverse direction `write(feed-parse(x))` re-frames to the same
|
||||
frame for any well-framed input.
|
||||
- **F-2 sentinel layering**: `len == 0` is a legal empty datagram,
|
||||
never EOF; the codec never emits zero-length datagrams for non-empty
|
||||
frames and never treats an empty datagram as stream end.
|
||||
- **consumption accounting**: re-framing all consumed bytes and
|
||||
feeding the byte-level remainder yields the same datagram stream
|
||||
(splitting a feed at any boundary is transparent).
|
||||
- **oversize frame rejection**: `frame_datagram` errors iff payload >
|
||||
`MAX_DATAGRAM_LEN`, echoing the sizes.
|
||||
- **pending-queue bound (the §1.3 finding-class assertion)**: total
|
||||
bytes retained across `buf` + `current` + `pending` after a feed is
|
||||
bounded by the fed chunk size plus the one in-flight frame —
|
||||
an adversarial peer chunk cannot force unbounded queue growth in
|
||||
the decoder state. (NOTE: this is the *decoder-state* bound; the
|
||||
consumer-side `DatagramHalves.pending` backlog between chunk-read
|
||||
and `recv_datagram` pulls is a session-shape question tracked in
|
||||
§4/§8 — the wave-2 stateful target, or a code fix if the bound is
|
||||
judged unacceptable. The fuzzer documents the amplification factor;
|
||||
the remediation decision is ours. **Wave-2 resolution (§3.3):** the
|
||||
session-shape backlog is now an asserted invariant — the harness's
|
||||
per-op drain holds the pending queue to the routed-batch remainder
|
||||
(≤ one chunk's worth of batched datagrams, drained by
|
||||
`recv_datagram` pulls), so the amplification question is bounded
|
||||
by construction + assertion rather than remediated; the
|
||||
`datagram_backlog()` diagnostic exposes the live count.)
|
||||
|
||||
### 3.2 `params_parse` (wave 1)
|
||||
|
||||
Input: raw bytes → `serde_json` (with alkcall's bounded
|
||||
`arbitrary_value` approach — depth/width-capped `Value` generation) →
|
||||
`TunnelParams` / `TunnelDirectParams` / `TunnelForwardedParams` /
|
||||
`SubstrateAddr` deserialization + `parse_listen_reply` /
|
||||
`listen_reply_fields` (`src/params.rs`). Invariants:
|
||||
|
||||
- **no-panic on any JSON-shaped input** (untagged-enum serde dispatch,
|
||||
`deny_unknown_fields`, the `positive_u32` pin).
|
||||
- **clean failure only**: parse failures are `Err`, never panics;
|
||||
`parse_listen_reply` returns `TunnelError::MalformedReply` on
|
||||
malformed shapes.
|
||||
- **round-trip for valid params**: `from_json(to_json(p)) == p` for
|
||||
constructed params of every substrate.
|
||||
- **deny-unknown posture**: unknown top-level fields reject;
|
||||
untagged-variant leniency (extra fields inside a `SubstrateAddr`
|
||||
variant) is the documented asymmetry — asserted, not "fixed".
|
||||
- **the `listen: 0` pin**: zero rejects at serde level.
|
||||
- **projection agreement**: `listen_reply_fields` yields both-`Some`
|
||||
iff `parse_listen_reply` is `Ok`; it never panics and never refuses
|
||||
construction (the lenient view projects malformed to both-`None`).
|
||||
- **establishment-reason surface**: `establishment_reason` never
|
||||
panics on arbitrary `ChannelOpenError` shapes.
|
||||
|
||||
### 3.3 `session_pumps` (wave 2)
|
||||
|
||||
Input: op sequences (`#[derive(Arbitrary)]` `SessionSequence` — the
|
||||
`manager_routing` pattern) over one live channels stack per exec:
|
||||
MuxRunner → raw chunk stream → the real `run_demux_loop_for_client`
|
||||
demux → `ChannelManager` per side (producer Connect / consumer
|
||||
Accept over one duplex transport — the reverse POC's topology). Ops:
|
||||
`Route` (producer→consumer framed datagrams / raw stream bytes via
|
||||
`route_payload`), `AdoptDatagram` / `AdoptStream` (the
|
||||
`TunnelSession` data-plane mirrors — codec reader + `pending`, and
|
||||
`pump_bidi` against in-process peer halves), `Pump` (peer→channel;
|
||||
`len == 0` drops the p2c source — the EOF path), `Teardown`,
|
||||
`ClearAll`. Invariants:
|
||||
|
||||
- **no-panic / no-wedge on any op sequence** (the baseline; the
|
||||
harness's 5 s reconcile bound turns a wedged pump into a finding,
|
||||
and §5's `-timeout=25` fork containment is the outer net).
|
||||
- **two-pump shutdown-on-completion (convention 8)**: after the p2c
|
||||
source drops, the pump's channel-side shutdown must propagate and
|
||||
the pump must complete once the teardown EOFs the c2p source —
|
||||
pinned by the `reconcile` join with the 5 s bound.
|
||||
- **lossless p2c copy**: the pump's `p2c` count equals exactly what
|
||||
the harness pushed into the peer half (copy never truncates).
|
||||
- **EOF-sentinel teardown semantics**: after a sentinel (teardown /
|
||||
`clear_all`), the reassembled stream drains its queue before
|
||||
reporting EOF — every routed datagram consumable, nothing lost;
|
||||
the consumer map empties; the codec's `len == 0` sentinel layering
|
||||
holds at the session layer (a routed empty datagram is data; the
|
||||
channels EOF is a separate mechanism).
|
||||
- **FIFO reconciliation**: every complete frame routed through the
|
||||
real demux path arrives as exactly one datagram of the declared
|
||||
length, in order (the model walks the framing as the codec does —
|
||||
the wave-1 F-2 walk, reused at the session layer).
|
||||
- **pending-queue bound (the §1.3 finding-class assertion)**: the
|
||||
session-shape `DatagramHalves.pending` backlog never exceeds the
|
||||
unrouted remainder — i.e. the backlog holds only datagrams decoded
|
||||
from routed chunks beyond the ones consumed; with the harness's
|
||||
per-op drain the queue returns to zero after every op, so an
|
||||
amplification factor > 1× chunk size would trip the assert. (The
|
||||
wave-1 decoder-state bound re-asserted per op alongside.)
|
||||
- **monotonicity**: the manager's early-arrival / dropped counters
|
||||
survive `clear_all` (the alkcall §7.7 pattern), and the consumer
|
||||
map empties on the transport-EOF path.
|
||||
|
||||
Bounds: 128 ops, ≤ 512-byte payloads (generator-clamped; the codec's
|
||||
`MAX_DATAGRAM_LEN` stays the wire bound). Even-id masking (ADR-047
|
||||
§5 — the consumer is the Accept side; the producer allocates even).
|
||||
|
||||
## 4. Scope: two waves
|
||||
|
||||
**Wave 1 (complete):** the two targets above. Both drive already-
|
||||
`pub` APIs — no `#[cfg(fuzzing)]` exposure needed (matching alkcall's
|
||||
experience where none of five targets needed it).
|
||||
|
||||
**Wave 2 (this session):** `session_pumps`, the stateful op-sequence
|
||||
target over the two-pump tunnel shape in the alkcall `manager_routing`
|
||||
style (`#[derive(Arbitrary)]` op enum; live mux + demux runners; the
|
||||
alkcall harness lessons applied: even-id masking, drained-state
|
||||
asserts). Drives: the `TunnelSession` data-plane shapes (the datagram
|
||||
codec plane + the `pump_bidi` stream plane) over a REAL channels
|
||||
stack — the two-pump shutdown-on-completion contract (AGENTS.md
|
||||
convention 8), the EOF-sentinel teardown semantics, and the §1.3
|
||||
backlog bound as byte-accounting invariants (§3.3). The
|
||||
`forwarded_routing` correlation target stays deferred until the
|
||||
assembly-side correlation registry (ADR-008 §3, assembly
|
||||
bookkeeping) grows into this crate.
|
||||
|
||||
Sequencing mirrors alkcall §7.4: init + wave-1 targets → 10–30 min
|
||||
campaigns per target via the detached runner → fix anything found →
|
||||
wave 2 (`session_pumps`) → further targets when justified.
|
||||
|
||||
## 5. Operational isolation: campaigns never share fate with the agent session
|
||||
|
||||
**This is an environment constraint, not a nice-to-have** (upstream
|
||||
doc §7.6, adopted verbatim as policy here). Agent sessions (opencode)
|
||||
run fuzzer builds/campaigns through a bash tool that spawns the fuzzer
|
||||
as a **child of the session's own process tree and cgroup**. A fuzz
|
||||
run is precisely the workload shape that kills its own host:
|
||||
|
||||
- **OOM-killer shared fate**: the target's worst case IS unbounded
|
||||
allocation. If libFuzzer's soft RSS guard loses the race with the
|
||||
kernel, the OOM killer fires — and it picks the largest-RSS process
|
||||
in the cgroup, which can be the opencode server hosting the session.
|
||||
That is *recoverable* (sessions are recorded in a DB) but hugely
|
||||
derailing — the session's live environment access dies mid-task.
|
||||
An OOM in a fuzz target must cost the fuzzer, never the session.
|
||||
- **Fork bombs / CPU saturation**: `-fork=N` workers; a runaway
|
||||
campaign starves the session host.
|
||||
- **Wedged targets**: a deadlock in an async harness would hang a
|
||||
foreground bash call forever.
|
||||
|
||||
Three layers of defense, cheapest first — **all mandatory**:
|
||||
|
||||
1. **libFuzzer soft limits (always on)**: `-rss_limit_mb=2048`
|
||||
`-malloc_limit_mb=2048` make libFuzzer exit cleanly (artifact
|
||||
recorded) before the kernel gets involved. Note the RSS limit is
|
||||
*soft* (polls `/proc/self/statm`); a fast huge allocation can beat
|
||||
it — that is what layers 2–3 are for. Never `ulimit -v` with ASAN
|
||||
(it reserves terabytes of virtual address space); the ASAN-safe
|
||||
hard-limit recipe is `-fork=1`.
|
||||
2. **Fork mode as blast-radius containment (default)**: `-fork=1`
|
||||
(or a small number) runs each input in a short-lived child — a
|
||||
crash/timeout/OOM/leak kills only that child; the harness survives,
|
||||
records the artifact, keeps fuzzing. Also the deadlock answer: the
|
||||
per-input timeout kills the child, not the session's bash call.
|
||||
3. **Process detachment from the session tree (MANDATORY for agent-run
|
||||
campaigns)**: never run fuzzing as a foreground child of the
|
||||
session. The runner script wraps `cargo fuzz run` in `setsid` +
|
||||
`nohup` + full redirection to a log file: (a) removes the fuzzer
|
||||
from the session's controlling terminal and signal relation, (b)
|
||||
survives the session ending, (c) gives the agent a pollable log +
|
||||
artifact directory instead of a blocking call. Combined with fork
|
||||
mode, an OOM-class finding costs one child process.
|
||||
|
||||
**The runner** (`fuzz/run-detached.sh`, CWD-independent — resolves its
|
||||
own repo root):
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Detached fuzzing runner for agent sessions: the campaign never runs
|
||||
# as a foreground child of the session (an OOM in a target must cost
|
||||
# the fuzzer, never the agent host), and survives the session ending.
|
||||
set -euo pipefail
|
||||
target="${1:?usage: run-detached.sh <target> [extra libfuzzer args...]}"
|
||||
shift
|
||||
runtime="${FUZZ_RUNTIME_SECS:-600}"
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
log_dir="$root/fuzz/artifacts"
|
||||
mkdir -p "$log_dir"
|
||||
log="$log_dir/${target}-$(date -u +%Y%m%d-%H%M%S).log"
|
||||
cd "$root"
|
||||
setsid nohup cargo fuzz run "$target" -- \
|
||||
-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 \
|
||||
-max_total_time="$runtime" "$@" \
|
||||
>"$log" 2>&1 < /dev/null &
|
||||
echo "pid=$! log=$log"
|
||||
```
|
||||
|
||||
**Agent polling protocol** (never `wait` on the detached process from a
|
||||
tool call — poll instead):
|
||||
|
||||
- `tail -n 50 fuzz/artifacts/<target>-*.log` — campaign progress.
|
||||
- `ls fuzz/artifacts/` — `crash-*` / `oom-*` / `timeout-*` / `leak-*`
|
||||
artifacts are the findings.
|
||||
- `pgrep -f "cargo fuzz run <target>"` — still running?
|
||||
- One detached campaign per target at a time; log filenames carry the
|
||||
UTC timestamp.
|
||||
|
||||
**Flags worth pinning beyond the runner defaults**: `-max_len=65536`
|
||||
on the codec target; `-use_value_profile=1`; a JSON token `-dict` for
|
||||
the params target (alkcall's `json.dict` transfers as-is).
|
||||
|
||||
## 6. No hosted CI
|
||||
|
||||
Per alkcall §7.9 — the standing policy applies to this repo identically:
|
||||
no hosted CI will exist; do not add Actions/Gitea-Actions workflow
|
||||
files. **Corpus replay is the fuzz gate as plain `cargo test`**:
|
||||
`cargo test --manifest-path fuzz/shared/Cargo.toml` replays every
|
||||
committed seed through the invariant functions — add it to the release
|
||||
verification checklist (AGENTS.md). Campaigns run manually via the
|
||||
detached runner (§6) — e.g. before a release, or after touching
|
||||
`src/wire.rs`, the consumer data planes, or `src/params.rs`.
|
||||
|
||||
## 7. Implementation log
|
||||
|
||||
- **2026-09-28 — proposal adopted** (this doc). Wave-1 targets
|
||||
`datagram_codec` + `params_parse`; wave-2 deferred (§4).
|
||||
- **2026-09-28 — `alkcall` bumped 0.8.0 → 0.8.1.** Bug-fix release, no
|
||||
API/wire change; the fix (duplicate adopt/open no longer destroying
|
||||
the live channel) is directly relevant to this crate's
|
||||
`TunnelSession::adopt` path — the changelog's "open-op response
|
||||
replayed / id re-announced" scenario is our reverse path. Verification:
|
||||
full suite (135 passed), clippy, fmt, wasm check (§8 commands).
|
||||
- **2026-09-28 — wave-1 implemented.** `fuzz/` workspace laid out per
|
||||
§2 (nightly-pinned subtree, stable `fuzz/shared` library, runner,
|
||||
dict, generator); `datagram_codec` + `params_parse` targets over
|
||||
already-`pub` APIs — no `#[cfg(fuzzing)]` exposure needed. One new
|
||||
public diagnostic: `DatagramReader::retained_bytes()` (the
|
||||
decoder-state byte count the §3.1 bound asserts against, in the
|
||||
`is_mid_datagram` diagnostic style). Seeds committed: 27 +
|
||||
69 generated (fuzz/gen_fuzz_seeds.py) + 1 campaign-found crash
|
||||
input promoted to `datagram_codec/seed-900` (see the finding below).
|
||||
Stable side: corpus replay green, shared crate clippy/fmt clean;
|
||||
main suite untouched and green; `cargo fuzz build` clean.
|
||||
- **2026-09-28 — first campaign finding: a HARNESS-model bug, not a
|
||||
crate bug.** The `datagram_codec` campaign crashed in its first
|
||||
minute on input `00 2b 01 00 00 05` (`fuzz/artifacts/datagram_codec/
|
||||
crash-e9ba284e…`, exit 77, fork-mode containment worked as
|
||||
designed — the harness parent survived and persisted the artifact).
|
||||
Analysis: the codec is CORRECT — `00 2b` declares a 43-byte
|
||||
datagram; `01 00 00 05` is its in-flight payload; nothing is
|
||||
yielded. The invariant that tripped ("an empty datagram must come
|
||||
only from a [00 00] length header") was mis-modeled: it counted
|
||||
`00 00` byte pairs anywhere in the RAW stream, but a `00 00` pair
|
||||
sitting INSIDE a frame's payload region is payload, not a length
|
||||
header — exactly the same class as alkcall §7.7's probe (the
|
||||
harness claimed `4 + len` when the prefix counts only the body).
|
||||
Fix: the count now walks the framing as the decoder does (complete
|
||||
frames from the start; the walk stops where the decoder would be
|
||||
stuck mid-frame). The crash input is committed as seed-900 and
|
||||
replays clean; the campaign relaunched clean.
|
||||
- **2026-09-28 — second harness finding (caught by corpus replay,
|
||||
pre-campaign): `establishment_reason` is a pass-through extractor,
|
||||
not a validated table.** The params.rs doc names the pinned reason
|
||||
strings, but the upstream implementation (alkcall
|
||||
`channels/client.rs` `establishment_reason`) extracts
|
||||
`details.reason` AS-IS whenever the code is `channel:open_failed` —
|
||||
a made-up reason is surfaced as-is by design, and validation is the
|
||||
consumer's job. The harness first asserted a closed set of reason
|
||||
strings and corpus replay tripped it on `made_up_reason` before any
|
||||
campaign ran. Invariant corrected to assert the extraction shape
|
||||
(Some iff code == `channel:open_failed` and reason is a string).
|
||||
- **2026-09-28 — second harness finding (same replay pass):
|
||||
`is_mid_datagram` means a partial DATAGRAM, not buffered header
|
||||
bytes.** A dangling 1-byte length prefix at input end is buffered
|
||||
header state (no frame in flight, `is_mid_datagram == false`);
|
||||
the harness first asserted `true` for that case. Corrected:
|
||||
mid-datagram iff the framing walk leaves a remainder ≥ 2 bytes
|
||||
(a parsed-but-unfinished payload), not `!= 0`.
|
||||
- **2026-09-28 — wave-1 campaigns run (10-min detached runs, §6 runner;
|
||||
`-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25`;
|
||||
codec with `-max_len=65536`, params with `-dict=json.dict
|
||||
-use_value_profile=1`). Both exited 0.**
|
||||
- `params_parse` — 3.14M execs (~5.3k exec/s), 33 fork jobs,
|
||||
`oom/timeout/crash: 0/0/0` throughout. Coverage 2049 edges /
|
||||
15.6k features; in-memory corpus 69 committed seeds → 3018
|
||||
entries — the serde/untagged-enum shape space was still expanding
|
||||
at budget end, so longer campaigns keep paying here.
|
||||
- `datagram_codec` (post-harness-fix) — 827k execs, 33 fork jobs,
|
||||
`oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 221
|
||||
edges (ft 1198) — the 2-byte-header state machine is fully
|
||||
enumerated, matching alkcall §7.7's `chunk_header` saturation
|
||||
profile. Exec/s reads low (~400) because each exec replays the
|
||||
input through a ~35-feed 3-byte-sliced sequence alongside the
|
||||
whole-input feed; the state space is small and exhausted early.
|
||||
- The pre-fix `datagram_codec` run (the §7 finding above) crashed at
|
||||
job 4 and exited 77; fork-mode containment behaved as designed —
|
||||
the harness survived, persisted the artifact, and the run wrapped
|
||||
up. That artifact is the promoted seed-900.
|
||||
- Grown corpora (3018 params entries) were **not** merged into the
|
||||
committed seeds — per the §2 policy, grown corpora stay
|
||||
gitignored.
|
||||
- Operational note for future sessions: `cargo fuzz run` returns
|
||||
before its last fork-mode children fully exit; a `pkill -f
|
||||
"cargo fuzz run <target>"` followed immediately by another
|
||||
`pgrep` of the same pattern can wedge a bash tool call on its own
|
||||
process match — prefer `pgrep -af` to LIST, kill by explicit pid
|
||||
from the runner's echoed `pid=`, and never pattern-kill-and-recheck
|
||||
in one call.
|
||||
- **NO crate bugs found by wave 1.** The two findings this session
|
||||
were both harness-model defects (§7 above), each invisible to
|
||||
example-based review and caught by fuzzing/replay — the alkcall §7.7
|
||||
"fuzzer as a harness-checker" pattern, confirmed. The §1.3
|
||||
decoder-state bound held everywhere: `retained_bytes()` never
|
||||
exceeded fed + one frame on any adversarial input. The session-shape
|
||||
`DatagramHalves.pending` amplification remains a documented
|
||||
§1.3/§4 note (wave-2's motivation), not a campaign finding — the
|
||||
wave-1 targets assert the decoder, not the session pump.
|
||||
- **2026-09-28 — wave-2 implemented: `session_pumps` (the stateful
|
||||
two-pump/EOF-sentinel target, §3.3).** The harness mirrors
|
||||
`TunnelSession`'s data-plane shapes over one REAL channels stack
|
||||
per exec (MuxRunner → raw chunk stream → the real
|
||||
`run_demux_loop_for_client` demux → `ChannelManager` per side over
|
||||
one duplex transport; producer Connect / consumer Accept — the
|
||||
reverse POC's topology). Ops: Route/AdoptDatagram/AdoptStream/
|
||||
Pump/Teardown/ClearAll; invariants per §3.3. One diagnostic
|
||||
addition to the crate: `TunnelSession::datagram_backlog()` (the
|
||||
`DatagramHalves.pending` count the §3.3 bound asserts against, in
|
||||
the `retained_bytes()` diagnostic style) + a suite test pinning
|
||||
its rest states. Seeds: 8 committed (deterministic decode-scan
|
||||
hex patterns, the `manager_routing` precedent — arbitrary ships
|
||||
no encoder) + 2 in-source semantic fixtures (the §1.3 batched-
|
||||
backlog probe; the W4 half-close shape). Two harness-model
|
||||
findings caught by corpus replay pre-campaign:
|
||||
- **the p2c-EOF pump-join wedge is the DESIGNED half-close
|
||||
shape, not a wedge.** The first `reconcile` draft awaited the
|
||||
pump after dropping only the p2c source — but the c2p pump's
|
||||
source (the channel read half) EOFs only via teardown, so the
|
||||
join legitimately blocked (the 5 s bound tripped). `pump_bidi`
|
||||
completes when BOTH pumps finish; the half-close contract is
|
||||
that the p2c completion shuts the channel side down (the
|
||||
observable), and the pump completes once the c2p source EOFs
|
||||
too. The harness now models exactly that: drop p2c source →
|
||||
teardown the entry → join with the 5 s bound. (The alkcall §7.7
|
||||
harness-checker pattern again — the fuzzer harness's first
|
||||
campaign-found crash in wave 1 and this replay catch are both
|
||||
harness-model defects, not crate bugs.)
|
||||
- **the stream-plane c2p accounting is asynchronous** — the pump
|
||||
writes channel→peer into the peer duplex while a far-end reader
|
||||
task counts; the reconcile join (after the pump completes) is
|
||||
when the count is final. First draft read it early. Fixed by
|
||||
reading after the reader task's join.
|
||||
- **2026-09-28 — third harness finding (campaign-found, fork-mode
|
||||
containment as designed): duplicate adopt is a LEGAL manager
|
||||
outcome, not a finding.** The first campaign crashed at ~49 s on
|
||||
`AdoptDatagram { channel_id: 0 }` after the id had already been
|
||||
adopted — `ChannelExists` is the manager's documented
|
||||
id-collision error, and my adopt arms treated every error as a
|
||||
finding. Fix: both adopt arms model the documented error table
|
||||
(`ChannelExists` / `TooManyChannels` are no-ops, exactly the
|
||||
alkcall `manager_routing` harness's treatment). The crash input
|
||||
is committed as seed-900 and replays clean; the campaign
|
||||
relaunched clean.
|
||||
- **2026-09-28 — fourth harness finding (second campaign): the
|
||||
harness modeled the producer→consumer path through the WRONG
|
||||
primitive.** The second campaign crashed at ~5 s on `Route`:
|
||||
raw-id addressing (`even_id` masked arbitrary ids; collisions +
|
||||
an `UnknownChannel`-class state the model didn't track), `len ==
|
||||
0` raw routes doubling as EOF sentinels mid-model, and — the
|
||||
structural one — `route_payload` was used as the producer's
|
||||
forward write. `route_payload` routes into the LOCAL manager's
|
||||
channel entry (the demux direction); the producer pump handler
|
||||
writes the channel's SEND half (the mux pump frames onto the
|
||||
transport). Bytes never reached the consumer. Rewrite (the real
|
||||
protocol shape, matching `manager_routing`'s op-vocabulary
|
||||
lesson): producer `Open` (`open_channel` allocates the id, the
|
||||
harness holds the send half — the pump handler's write side),
|
||||
session-INDEX addressing (no masking, no collisions), `Adopt`
|
||||
consumes the producer-allocated id (the early-arrival drain is
|
||||
the real adopt-race path), `Route` writes the producer send half
|
||||
(the real wire path: mux pump → transport → consumer demux →
|
||||
reassembly), `Pull` (the `recv_datagram` pull pops the backlog +
|
||||
the model), and a STATEFUL framing model (partial frames persist
|
||||
across ops — the wave-1 walk carried across ops). EOF sentinels
|
||||
now arise only from teardown/sender-drop (the real mechanisms),
|
||||
never from `len == 0` routes (F-2: a routed empty datagram is
|
||||
DATA). All committed session_pumps seeds regenerated by the
|
||||
rewrite (the old hex patterns decode against the new op enum);
|
||||
the semantic fixtures re-pinned. Verification of the fix: the
|
||||
campaign's 0-exec crash went away; batched frames now reconcile
|
||||
FIFO through the real two-manager stack.
|
||||
- **2026-09-28 — fifth + sixth harness findings (third campaign's
|
||||
first minutes): the two ClearAll modeling defects.** (5) A
|
||||
double-adopt of a live session panicked the harness's own guard —
|
||||
but a re-adopt of a LIVE id is the manager's documented
|
||||
`ChannelExists` outcome (a legal no-op; alkcall 0.8.1's
|
||||
insert-replace fix leaves the live entry intact). Modeled as a
|
||||
no-op. (6) A `ClearAll` followed by `Route` on the same session
|
||||
tripped the backlog reconciliation — the harness retired only the
|
||||
sessions whose ids appeared in `clear_all`'s RETURN value, but
|
||||
that value is the OPENER-LEDGER records (ADR-047 §7): adopted
|
||||
channels carry no ledger entry, so the set is empty while the map
|
||||
itself empties. The alkcall §7.7 lesson ("`drained.len() ==
|
||||
open_count()` is not an invariant"), learned again at this
|
||||
crate's layer — the map-empties assert is the ground truth; the
|
||||
harness now retires EVERY session on `ClearAll`.
|
||||
- **2026-09-28 — wave-2 campaign run (10-min detached run, §6
|
||||
runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048
|
||||
-timeout=25 -max_len=4096 -use_value_profile=1`): exited 0.**
|
||||
228,676 execs (~380 exec/s; each exec replays up to 128 ops on a
|
||||
live two-manager stack with real mux/demux tasks), 32 fork jobs,
|
||||
`oom/timeout/crash: 0/0/0` throughout. Coverage 3427 edges /
|
||||
17,529 features / 1184 in-memory corpus entries and STILL
|
||||
EXPANDING at budget end (the op-interleaving space is much larger
|
||||
than the parser targets') — longer campaigns keep paying. The
|
||||
two-pump contract held across every adversarial sequence: no
|
||||
wedged pump (the 5 s reconcile join), no lost datagram (the
|
||||
FIFO/backlog reconciliation), no premature EOF (the sentinel
|
||||
layering). The §1.3 backlog bound held everywhere: the
|
||||
`DatagramHalves.pending` queue never exceeded the
|
||||
routed-but-unpulled remainder.
|
||||
- **NO crate bugs found by wave 2.** All six findings across waves
|
||||
1–2 were harness-model defects (four this session), each
|
||||
invisible to example-based review and caught by fuzzing/replay —
|
||||
the alkcall §7.7 "fuzzer as a harness-checker" pattern,
|
||||
re-confirmed. The stateful thesis is validated from the harness
|
||||
side: the harness defects themselves (double-adopt semantics, the
|
||||
clear_all ledger/map distinction, the route_payload direction)
|
||||
are exactly the interleaving-shape mistakes the alkcall §7.8
|
||||
campaigns caught in their own drivers.
|
||||
- (progress appended as implementation proceeds — see git log)
|
||||
|
||||
## 8. Verification commands
|
||||
|
||||
```bash
|
||||
cargo test # main suite (stable)
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay (fuzz gate)
|
||||
cargo fuzz build # in fuzz/ (nightly, ASAN)
|
||||
fuzz/run-detached.sh <target> # campaigns (never foreground)
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
cargo clippy --manifest-path fuzz/shared/Cargo.toml --all-targets -- -D warnings
|
||||
cargo fmt --manifest-path fuzz/shared/Cargo.toml --check
|
||||
cargo check --target wasm32-unknown-unknown # default crate stays wasm-clean
|
||||
```
|
||||
|
||||
Campaign flags per target: `datagram_codec -max_len=65536`;
|
||||
`params_parse -dict=json.dict -use_value_profile=1`;
|
||||
`session_pumps -max_len=4096 -use_value_profile=1`.
|
||||
|
||||
## 9. References
|
||||
|
||||
- Upstream campaign + operational model (authoritative for tooling,
|
||||
runner, corpus policy, no-CI posture):
|
||||
`/workspace/@alkdev/alkcall/docs/research/fuzzing.md`
|
||||
- alkcall 0.8.1 changelog — the finding our adopt path is exposed to
|
||||
- This crate: ADR-003 (codec framing), ADR-002 (ALPN), ADR-007/008
|
||||
(direct/forwarded ops), `docs/architecture/wire.md`
|
||||
- External: RUSTSEC-2026-0037 (quinn-proto — the maintainers'
|
||||
fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust
|
||||
Fuzz Book (all linked from the upstream doc's §9)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,10 @@
|
||||
target
|
||||
corpus/*
|
||||
!corpus/datagram_codec
|
||||
!corpus/params_parse
|
||||
!corpus/session_pumps
|
||||
# Grown corpus entries (libFuzzer hash-named) are gitignored; only the
|
||||
# hand-made seed-* files are committed (the quinn/h2 policy).
|
||||
corpus/*/[0-9a-f]*
|
||||
artifacts
|
||||
coverage
|
||||
Generated
+1297
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "alktunnels-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
alktunnels-fuzz-shared = { path = "shared" }
|
||||
|
||||
[dependencies.alktunnels]
|
||||
path = ".."
|
||||
|
||||
[[bin]]
|
||||
name = "datagram_codec"
|
||||
path = "fuzz_targets/datagram_codec.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "params_parse"
|
||||
path = "fuzz_targets/params_parse.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "session_pumps"
|
||||
path = "fuzz_targets/session_pumps.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,58 @@
|
||||
# alktunnels fuzzing
|
||||
|
||||
libFuzzer targets for alktunnels' untrusted-input surfaces (see
|
||||
`docs/research/fuzzing.md` for the full rationale, scope decision, and
|
||||
campaign plan; the tooling/corpus/runner/no-CI posture is inherited
|
||||
from alkcall's `docs/research/fuzzing.md`).
|
||||
|
||||
## Layout
|
||||
|
||||
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers.
|
||||
- `shared/` — the invariant logic, as a plain library so normal
|
||||
`cargo test` (stable toolchain) can replay the committed corpora
|
||||
through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]`
|
||||
modules; quinn's CI pattern). The fuzz binaries are nightly-only;
|
||||
the shared crate is stable-clean.
|
||||
- `corpus/<target>/` — committed hand-made seeds (regenerate with
|
||||
`python3 fuzz/gen_fuzz_seeds.py` from the repo root). Grown corpora
|
||||
and artifacts are gitignored.
|
||||
- `run-detached.sh` — mandatory runner for agent sessions: wraps
|
||||
`cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM
|
||||
in a target can never take down the agent host (fuzzing.md §5).
|
||||
- `json.dict` — JSON token dictionary for the params target
|
||||
(transferred from alkcall).
|
||||
|
||||
## Targets
|
||||
|
||||
| Target | Drives | Invariants |
|
||||
|---|---|---|
|
||||
| `datagram_codec` | `DatagramReader::feed` / `frame_datagram` (`src/wire.rs`) | no-panic; infallible decode; yielded datagrams ≤ `MAX_DATAGRAM_LEN`; F-2 sentinel layering (empty datagram ≠ EOF, only from `[00 00]` headers); splitting transparency (3-byte slices == whole-input feed); re-frame round-trip; oversize rejection exactly at the bound; `is_mid_datagram` tracks framing; decoder-state retained-bytes bound |
|
||||
| `params_parse` | `TunnelParams` / `TunnelDirectParams` / `TunnelForwardedParams` / `SubstrateAddr` serde parses + `parse_listen_reply` / `listen_reply_fields` / `establishment_reason` (`src/params.rs`) | no-panic on raw bytes and structured Values; clean typed failure only; round-trip for valid params; `listen: 0` pin; loud/lenient projection-pair agreement; pinned establishment-reason table |
|
||||
| `session_pumps` | op sequences (`SessionSequence`) over one live channels stack per exec (MuxRunner → real demux → `ChannelManager` per side; the `TunnelSession` data-plane mirrors — codec plane + `pump_bidi` stream planes) | no-panic / no-wedge (5 s reconcile bound); two-pump shutdown-on-completion; lossless p2c copy count; EOF-sentinel teardown semantics (drain-before-EOF, map empties); FIFO datagram reconciliation (framing-walk model); `pending` backlog ≤ routed-batch remainder (the §1.3 bound); decoder-state retained-bytes bound re-asserted per op; monotonic counters |
|
||||
|
||||
## Commands
|
||||
|
||||
`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/`
|
||||
(or deeper) as the working directory so rustup selects the pinned
|
||||
nightly toolchain — the detached runner handles that itself.
|
||||
|
||||
```sh
|
||||
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
|
||||
cargo fuzz build
|
||||
|
||||
# agent sessions: detached campaign (never foreground; CWD-independent)
|
||||
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh datagram_codec -max_len=65536
|
||||
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh params_parse -dict=json.dict -use_value_profile=1
|
||||
FUZZ_RUNTIME_SECS=600 fuzz/run-detached.sh session_pumps -max_len=4096 -use_value_profile=1
|
||||
|
||||
# corpus replay through the invariants (stable toolchain, no nightly)
|
||||
cargo test --manifest-path fuzz/shared/Cargo.toml
|
||||
|
||||
# coverage
|
||||
cargo fuzz coverage <target>
|
||||
```
|
||||
|
||||
The fuzz workspace is excluded from the main workspace and from the
|
||||
published package (`exclude` in the root `Cargo.toml`); it pins its own
|
||||
nightly toolchain via `rust-toolchain.toml` and does not affect the
|
||||
crate's stable MSRV.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
��bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbLine truncated
|
||||
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
��
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
��
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
4
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "postgres-primary", "substrate": "tcp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "postgres-primary", "substrate": "udp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "sock", "substrate": "unix"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "r", "substrate": "tcp", "extra": true}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "r"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "", "substrate": ""}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "substrate": "tcp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": null, "substrate": "tcp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"resource": 1, "substrate": 2}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": {"host": "db.internal", "port": 5432}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "udp", "target": {"host": "10.0.0.7", "port": 53}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "unix", "target": {"path": "/run/x.sock"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": {"port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": {"host": "h"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "unix", "target": {"path": "/p", "host": "h"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": {"host": "h", "port": 65536}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": {"host": "h", "port": -1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": [1, 2]}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "unix", "target": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "target": {}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"target": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 918273, "substrate": "tcp", "peer": {"host": "198.51.100.4", "port": 55231}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 918273, "substrate": "unix", "peer": {"path": "/run/x.sock"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 0, "substrate": "tcp", "peer": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": -1, "substrate": "tcp", "peer": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 1, "substrate": "tcp", "peer": {"host": "h", "port": 1}, "channel": 7}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 4294967296, "substrate": "tcp", "peer": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": "token", "substrate": "tcp", "peer": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"substrate": "tcp", "peer": {"host": "h", "port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 1, "substrate": "tcp"}
|
||||
@@ -0,0 +1 @@
|
||||
{"listen": 1, "substrate": "unix", "peer": {"path": "/p", "host": "h"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel_id": 7, "bound": {"host": "203.0.113.9", "port": 42113}, "listen": 918273}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel_id": 7}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel_id": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel_id": 7, "bound": {"path": "/run/x.sock"}, "listen": 5, "future_field": {"n": true}}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel_id": 7, "bound": {"port": 1}}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel_id": 7, "listen": 0}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel_id": 7, "listen": "token"}
|
||||
@@ -0,0 +1 @@
|
||||
{"bound": {"path": "/p"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel_id": -7}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel_id": 7.5}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel_id": 7, "bound": 5}
|
||||
@@ -0,0 +1 @@
|
||||
{"channel_id": 7, "listen": 4294967296}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{"code": "channel:open_failed", "message": "establishment failed", "details": {"reason": "dial_failed"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"code": "channel:open_failed", "details": {"reason": "unknown_resource"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"code": "channel:open_failed", "details": {"reason": "resource_shortage"}}
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user