24 Commits
Author SHA1 Message Date
glm-5.3-flash 1365272146 release: v0.3.0 — the dependency-major (alkcall 0.9.0)
- Cargo.toml: alkcall 0.8.1 -> 0.9.0 (crates.io, the stub-sweep
  release) and 0.2.1 -> 0.3.0 (breaking dep bump + public alkcall
  re-exports -> minor, matching the 0.7.1 -> 0.8.0 ride on 0.2.0)
- fuzz/shared/Cargo.toml: alkcall 0.9.0 (path-linked alktunnels must
  see one alkcall version)
- src/producer.rs + tests/producer_direct_op.rs: the only code contact
  with the 0.9.0 breaking surface — Establishment no longer derives
  Debug; two expect_err calls -> .err().expect, one untyped
  panic!("{other:?}") fall-through -> typed Err/Ok arms
- CHANGELOG: 0.3.0 section + compare links
- README: the 0.3.0 note
- docs/architecture/overview.md: dep tree alkcall 0.9.0

Verification: cargo test (136 default / 163 with local), fuzz/shared
corpus replay (0 pass, empty target set — no regressions), clippy
--all-targets -D warnings (native + wasm32-unknown-unknown), fmt
--check, wasm32 check, and cargo publish --dry-run --allow-dirty all
green
2026-10-14 05:23:08 +00:00
glm-5.3-flash 05b25465aa release: v0.2.1 — the hardening (fuzzing adopted + alkcall 0.8.1)
- CHANGELOG: Unreleased cut to 0.2.1 (the fuzz/ workspace: three
  cargo-fuzz targets + stable-toolchain corpus replay in fuzz/shared;
  the two wave-2 diagnostics datagram_backlog/retained_bytes; the
  alkcall 0.8.0 -> 0.8.1 bump — duplicate adopt no longer destroys the
  live channel; compare links updated)
- README: the 0.2.1 hardening note (fuzzing + alkcall 0.8.1 + the two
  additive diagnostics)
- docs/architecture/overview.md: dep tree alkcall 0.8.1
- Cargo.toml: 0.2.0 -> 0.2.1

Verification: cargo test (136 default / 163 with local), fuzz/shared
corpus replay (5 pass), clippy --all-targets -D warnings (default,
all-features, wasm32-unknown-unknown, fuzz/shared), fmt --check, doc,
wasm32 check, and cargo publish --dry-run --allow-dirty all green
(42 packaged files — fuzz/, docs/research|reviews|plans/, tasks/,
.opencode/, AGENTS.md all excluded)
2026-09-28 10:21:48 +00:00
glm-5.3-flash e3c6f6c9f8 fuzz: wave 2 — session_pumps stateful two-pump target
The wave-2 stateful target from docs/research/fuzzing.md 3.3: op
sequences (arbitrary SessionSequence) 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). The harness mirrors TunnelSession's data-plane
shapes: datagram planes (codec reader + pending, the
DatagramHalves mirror) and stream planes (pump_bidi against
in-process peer halves, the pump_against mirror).

Invariants (3.3): no-panic/no-wedge (5s reconcile join); two-pump
shutdown-on-completion; lossless p2c copy count; EOF-sentinel
teardown semantics (drain-before-EOF, map empties); FIFO datagram
reconciliation through the real demux path (stateful framing walk,
the wave-1 walk carried across ops); the 1.3 DatagramHalves.pending
backlog bound (<= routed-but-unpulled remainder); decoder-state
retained-bytes bound re-asserted per op.

Harness-model findings (all six wave findings are harness defects,
not crate bugs - the fuzzer-as-harness-checker pattern):
- duplicate adopt is a legal ChannelExists no-op (documented table)
- the p2c-EOF pump join is the DESIGNED half-close shape; the pump
  completes once the c2p source EOFs too (teardown), not before
- route_payload routes into the LOCAL manager (demux direction);
  the producer pump writes the channel send half - the first
  campaign's bytes never reached the consumer; rewritten to the
  real protocol shape (Open allocates, session-index addressing,
  stateful framing model, Pull op, EOF sentinels only from
  teardown/sender-drop)
- clear_all's return is the opener-ledger records, NOT the reaped
  ids (adopted channels carry no ledger entry - the alkcall 7.7
  lesson re-learned); the map-empties assert is the ground truth

Campaign (10-min detached, 6 runner): 228,676 execs, 32 fork jobs,
oom/timeout/crash 0/0/0, exit 0. Coverage 3427 edges / 17.5k
features still expanding at budget end. No crate bugs found - the
two-pump contract, EOF-sentinel semantics, and the pending backlog
bound are demonstrated properties.

Crate addition: TunnelSession::datagram_backlog() (the
DatagramHalves.pending diagnostic the 3.3 bound asserts against, in
the retained_bytes() style) + a suite test pinning its rest states.

Verification: main suite 136 passed; corpus replay (8 committed
seeds + 2 semantic fixtures) green; clippy -D warnings + fmt clean
(main, shared, wasm); wasm check clean; cargo fuzz build clean.
2026-09-28 10:12:09 +00:00
glm-5.3-flash 57285d23bd fuzz: adopt fuzzing (wave 1) + alkcall 0.8.1 bump
Bump the substrate dependency to alkcall 0.8.1 — the bug-fix release
whose fix (duplicate adopt/open no longer destroying the live channel)
is directly relevant to this crate's reverse-path adopt flow.

Adopt fuzzing per docs/research/fuzzing.md, scoped smaller than
alkcall's (the envelope/chunk/demux/manager surfaces are upstream and
now fuzzed there; this crate fuzzes its own named downstream target —
the ADR-003 datagram codec — plus the params/reply parsers):

- fuzz/ workspace per the alkcall pattern: nightly-pinned subtree
  (rust-toolchain.toml), thin fuzz_target! binaries, and a
  stable-toolchain shared crate carrying the invariant logic so the
  committed corpus replays as plain `cargo test` (the fuzz gate in
  the release checklist — no hosted CI, alkcall §7.9 posture).
- Targets (both over already-pub APIs; no #[cfg(fuzzing)] exposure):
  - datagram_codec — DatagramReader::feed + frame_datagram: no-panic,
    infallible decode, size bound, F-2 sentinel layering (empty
    datagram only from a length-0 header at a frame boundary),
    3-byte-slice splitting transparency, re-frame round-trip,
    oversize rejection at the bound, is_mid_datagram framing state,
    and the decoder-state retained-bytes bound.
  - params_parse — serde params/reply parsers + establishment_reason:
    no-panic on raw bytes and bounded structured Values, clean typed
    failure, round-trips, listen: 0 pin, loud/lenient projection-pair
    agreement, pass-through reason extraction model.
- New public diagnostic: DatagramReader::retained_bytes() (decoder-
  state byte count; the fuzz bound asserts against it).
- 96 committed seeds (gen_fuzz_seeds.py) + the campaign-found crash
  input promoted as datagram_codec/seed-900; grown corpora gitignored.
- run-detached.sh: setsid+nohup runner so campaigns never share fate
  with the agent session process tree (fuzzing.md §5).

Campaign findings this session were harness-model defects, not crate
bugs (both invisible to example-based review; the alkcall
"fuzzer as harness-checker" pattern confirmed):

- the F-2 empty-datagram invariant first counted [00 00] pairs in the
  raw stream — a pair inside a payload region is payload, not a
  header; corrected to walk the framing (crash artifact saved).
- establishment_reason is a pass-through extractor of details.reason,
  not a validated table (harness first asserted a closed set).
- is_mid_datagram means a partial datagram, not buffered header bytes
  (a 1-byte header fragment is buffered, not in flight).

Verification: cargo test 135 passed; clippy -D warnings and fmt clean
(main + fuzz/shared); corpus replay green; cargo fuzz build clean;
wasm32-unknown-unknown check clean. Wave-1 campaigns (10-min detached,
fork mode): params_parse 3.14M execs / datagram_codec 827k execs,
0 crashes/OOMs/timeouts; coverage saturated on the codec state space.
2026-09-28 08:09:11 +00:00
glm-5.3-flash d789031863 release: v0.2.0 — the graduation (direct op + bind-first/forwarded metadata flow)
- README sync: the direct op (ADR-007), the bind-first establisher +
  the forwarded op (ADR-008), TunnelListener + the reply-field
  projection, the 0.2 summary section, the local bind-first helpers
- CHANGELOG: Unreleased cut to 0.2.0 (the graduation + the alkcall
  0.8.0 bump; compare links updated)
- Cargo.toml: 0.1.0 -> 0.2.0

Verification: cargo test (135 pass default / 162 with local), clippy
(all targets, -D warnings, both feature sets), fmt, doc, the
wasm32-unknown-unknown check + clippy, and cargo publish --dry-run
--allow-dirty all green
2026-09-20 09:30:21 +00:00
glm-5.3-flash 20834d297e docs: review 002 recorder — N-3/N-4 durable trail + N-2 ADR-008 widening + the no-action dispositions
- N-3: parse-before-accept telemetry note on make_forwarded_op_handler
  (the accepted-flag reads false for a malformed-params open by design)
- N-4: ADR-007 Consequences lines — the untagged-leniency-is-
  schema-gated paragraph + the overlapping-variants caution
- N-2: ADR-008 §1 widened to name the any-byte idle-read posture
  (the Ok(0)-only reading hangs on a write-happy peer)
- N-5: mint_token's doc comment records the 256-channel cap analysis
- The N-1/N-6/N-7 + G-1/G-3 no-action dispositions recorded in the
  task file (the tracker the task asked for)

Verified: fmt, clippy (native + wasm32), test, cargo doc — all green
2026-09-20 08:32:45 +00:00
glm-5.3-flash 7b06dc7aac docs: review 002 doc-sync — D-1 schema bounds in wire.md + D-2 ADR-007 oneOf erratum
- D-1: wire.md's Direct Op + Forwarded Op sections record the shipped
  schema constraints (port bounds, additionalProperties: false per
  branch, listen minimum: 1) and the registry-side gate ordering
- D-2: ADR-007 §2 corrected in place + a dated erratum (oneOf was
  wrong-shaped; validation is registry-side, not establisher-time);
  wire.md's mirrored sentence corrected

Verified: cargo doc --no-deps clean; cargo fmt --check
2026-09-20 08:31:47 +00:00
glm-5.3-flash f7dff25f9d docs: review 002 trivial batch — D-3 honest serde test name + D-4 CHANGELOG preamble trim
- D-3: inline the serde-layer check into the renamed
  serde_rejects_unknown_substrate_value; delete the overpromising
  jsonschema_valid helper (the registry's real schema gate stays
  pinned end-to-end by the integration probes)
- D-4: trim the stale Unreleased preamble to a one-liner (written
  before the six graduation implementation commits landed)

Verified: cargo fmt --check; cargo clippy --all-targets -D warnings;
cargo test (86 + 2 doc-tests) green
2026-09-20 08:30:48 +00:00
glm-5.3-flash 98afb092ce test: G-4 unix accept loop + G-5 unnamed fail-loud guards
- G-4: drive UnixListenerHalves::accept_loop — bind_unix + temp path,
  the loop exits on the queue-closed handler error after one unix
  client accept (the unix parity twin of the TCP accept-loop test;
  closes the largest single uncovered block, local/mod.rs 591-609).
- G-5: pin ADR-008 §3's two fail-loud sentences — bind_unix("") is
  the kernel autobind (no filesystem path) so local_addr fails loud
  (578-583), and a socket2 autobind client against a NAMED listener
  is refused by accept_pair's unnamed-peer guard (626-633; Linux —
  abstract addresses/autobind are Linux-shaped, mio maps any
  abstract-namespace peer to unnamed).

Verification: cargo test --features local green (162 tests);
clippy --all-targets -D warnings clean (native + wasm32-unknown-
unknown); cargo fmt --check clean.
2026-09-20 07:39:52 +00:00
glm-5.3-flash 7991bf6ad0 test: G-2 graceful-close far teardown + N-2 write-happy-peer posture
- G-2: observe TunnelListener::close's explicit shutdown sentinel at
  the far accept dispatcher — the accept loop cancels, the pushed
  accept is never consumed, the serving-side listen channel reaps
  (ADR-008 §1's teardown contract, graceful half).
- N-2 residue: pin the write-happy-peer teardown posture — a
  non-empty read on the idle listen stream tears the listener down
  (the fail-closed Ok(_) arm; driven via the raw client surface
  since the typed listener holds its write half idle by contract).

Verification: cargo test --features local green (163 tests + 2
doc-tests); clippy --all-targets -D warnings clean (native +
wasm32-unknown-unknown); cargo fmt --check clean.
2026-09-20 07:18:23 +00:00
glm-5.3-flash 57a728dc4b fix: bind-first dispatcher reads substrate from the plan (review 002 M-1)
- BindFirstPlan gains substrate: Substrate, stamped at establisher
  time from the one successful TunnelParams parse (one parse, one
  source of truth; the plan-payload change is pre-consumer)
- make_bindfirst_accept_handler drops the second params re-parse and
  the silent unwrap_or(Substrate::Tcp) fallback entirely — the
  dispatcher reads plan.substrate; no fallback branch remains, so a
  future drift cannot silently stamp Tcp into a UDP listen tunnel's
  forwarded opens (the raw-vs-framed data-plane mismatch class)
- tests: forwarded_opens_carry_plan_stamped_substrate pins the plan
  as the substrate's source over a real UDP bind-first wire flow
  (a substrate-recording forwarded stub observes substrate: udp);
  the unit downcast + establisher-flow tests pin the stamp
- docs: producer.md (the plan carries {listener, token, substrate};
  the forwarded open's substrate also comes from the plan),
  ADR-008 §1 (the stamp + the removed fallback)
- task review-002-m1-substrate-fallback: completed (Notes filled)

Verification: cargo test (145 pass), cargo test --all-features (153),
clippy --all-targets -D warnings (default + all-features), fmt
--check, doc --no-deps, wasm32 check + clippy -D warnings.
2026-09-20 07:00:18 +00:00
glm-5.3-flash 3df5c484c6 tasks: decompose review 002 findings into six remediation tasks
- M-1: the accept dispatcher's silent substrate fallback (plan-stamp
  shape recommended; failure-path test required)
- D-3 + D-4: trivial batch (rename the overpromising schema test
  helper; trim the stale CHANGELOG Unreleased preamble)
- D-1 + D-2: wire.md schema bounds + ADR-007 oneOf->if/then +
  establisher-time -> registry-gate sentence (doc-only, pre-consumer)
- G-2 (+ N-2's write-happy-peer test): observe close()'s EOF sentinel
  at the far accept dispatcher
- G-4 + G-5: unix accept-loop coverage + the unnamed unix fail-loud
  guards (G-1/G-3/G-6/G-7 explicitly left uncovered per the review)
- N-1..N-7 recorder: the two durable-trail notes (N-3 telemetry
  ordering sentence, N-4 ADR lines) + the no-action dispositions

All six independent (no depends_on) so sessions can parallelize;
validated with taskgraph (28 tasks, no cycles).
2026-09-20 06:46:06 +00:00
glm-5.3-flash 46d3a87b24 review 002: the graduation implementation — spec conformance + coverage appendix 2026-09-20 06:26:27 +00:00
glm-5.3-flash 474844e563 feat: TunnelListener — the listen-flow consumer session (ADR-008 §4)
- src/consumer.rs: `TunnelListener` — the listen-flow sibling of
  `TunnelSession`: `open` rides `open_channel_with_reply` (the U-2
  surface, alkcall 0.8.0) and projects the bind-first `bound`/`listen`
  reply fields via the lenient `listen_reply_fields`; `bound()` /
  `listen()` present them (the Option posture — None for dial/
  accept-first producers and old producers). Teardown ownership
  (ADR-005's posture, listen-flavored): `close` (graceful — `shutdown`
  on the held write half, the explicit EOF sentinel, then the sync
  reap) and `Drop` (the held write half drops → the mux pump's
  implicit-EOF chunk — the far accept dispatcher's teardown signal —
  then the sync reap; never leaks). The held stream is the write half
  (`MpscSendStream`) only, IDLE and private — a listen channel has no
  data-plane role. No `Clone` (compile_fail doc-test).
  `forwarded()` decided: NOT provided — the receipt queue is
  assembly-side bookkeeping; the type hands the assembly the token.
- src/lib.rs: re-exports `TunnelListener`; crate docs mention the
  listen-flow session.
- src/producer.rs: doc-fix drive-by — two pre-existing broken
  intra-doc links (cargo doc is now 0-warning clean).
- tests/harness.rs: `wire_listener_bindfirst` + `ListenerTopology` —
  the opener-dials shape (client + serving on the opener; bind-first
  serving + the call-plane forwarded-open caller on the producer).
- tests/consumer_listener.rs: 5 integration tests — the bind-first
  gate (reply fields + forwarded round-trip through the REAL
  forwarded-openable registration + the teardown cascade), the Option
  posture on a dial producer, close/Drop reaping (the leak-free
  guarantee), the adopt-path pin (`adopt` gains nothing — a forwarded
  channel presents the ordinary session semantics), and the scope
  interaction (`tunnel:open`-only opener opens the listen channel;
  forwarded opens to it fail FORBIDDEN).
- CHANGELOG: Unreleased → Added.

Verification: cargo test 132 passed (157 --all-features; doc tests
2/2 incl. both compile_fail asserts), clippy --all-targets -D warnings
clean (native + wasm32), fmt clean, wasm32-unknown-unknown check
passes (the default crate stays wasm-clean), cargo doc --no-deps clean.
2026-09-19 18:29:18 +00:00
glm-5.3-flash 514e0c1f25 feat: the local bind-first halves (shape 3 substrate half, ADR-008 §1)
- `bind_tcp_first` + `TcpListenerHalves::accept_pair`: the real-socket
  BindFn source (port 0 resolved via local_addr — the `bound` reply
  field's data) and the per-accept (peer, halves) pair (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 / queue-feeding accept_loop /
  bind-first accept_pair; an unnamed peer fails loud — the forwarded
  `peer` param requires an address) + `bind_unix_first`
- assembly conveniences: `local_bind_first()` (tcp/unix dispatch; udp
  fails loud — no accept on a UDP socket, the shape is not defined
  there, never a fake listener) + `local_bindfirst_accept_loop()`
  (downcasts the opaque listener; a foreign type fails loud)
- direct-op DialFn reuse verified + pinned: `local_dial` shared
  unchanged (the UDP framed wrap composes inside the closure);
  `direct_udp_through_local_dial_round_trips_framed` drives a REAL
  udp echo through the direct op's establisher path
- tests/local_bindfirst.rs: tcp e2e over real sockets (bound fidelity
  — the test dials the reported address; two clients → two forwarded
  channels pumping independently, real distinct peers), unix parity
  (real path in `bound`, named-peer client via socket2, real peer path
  on the forwarded open), udp refusal, downcast-miss probe
- accept-first shapes (accept_loop + AcceptQueue) untouched

Verified: cargo test (default) + --all-features green (45 lib; 151
integration), clippy -D warnings clean (native all-features + wasm32),
fmt clean, wasm32 check passes (default crate stays wasm-clean),
cargo doc clean (no new warnings).

Tasks: tunnels/local-bind-halves (status completed; Notes + Summary
filled). CHANGELOG: Unreleased → Added.
2026-09-19 16:30:46 +00:00
glm-5.3-flash 3fb26d9de6 feat: the forwarded op's serving side + the lenient reply projection (ADR-008 as amended)
- `ForwardedHandler` — the injected assembly handler (parsed params +
  channel `BiStream`); the assembly owns the pump, correlating by the
  `listen` token against its waiting-listener bookkeeping
- `make_forwarded_op_handler` — the built-in no-establisher wrapper:
  deny-unknown params parse (a malformed open fails loud → early
  return → teardown-at-birth reaps the channel, no phantom),
  `accept_bi` FIRST (R-02 accepted-flag telemetry), the injected
  handler's `JoinHandle` awaited inline (R-02)
- `register_tunnel_forwarded_openable` — `register_openable` shape
  (no establisher; the data plane starts immediately), scope gate
  `tunnel:forwarded` (ingress, never implied by `tunnel:open`/
  `tunnel:direct`)
- `listen_reply_fields` — the lenient, infallible reply projection
  (unknown extra reply fields tolerated; a malformed shape logs loud
  and surfaces both-`None`), shared with `TunnelListener`, delegating
  to `parse_listen_reply`
- Tests: 4 unit (reply projection) + 4 integration
  (serving-half e2e round-trip, FORBIDDEN gate, malformed-params
  teardown at both gates, correlation-miss teardown)

Verified: cargo test green (126 default; 146 --all-features),
clippy --all-targets -D warnings clean (native + wasm32), fmt clean,
wasm32-unknown-unknown check passes, cargo doc --no-deps clean
2026-09-19 14:48:08 +00:00
glm-5.3-flash 7d23a89791 feat: the bind-first establisher + accept dispatcher (shape 3, ADR-008 as amended)
- BindFn + BoundListener: the platform-boundary bind seam (the
  protocol never binds, OQ-TN-04); the opaque listener rides the plan
- bindfirst_establisher: registry namespace gate -> token mint
  (non-zero-seeded AtomicU32, per-open fresh, per-producer unique,
  token 0 never minted — the wrap shape unit-pinned) -> bind ->
  `bound` + `listen` reply fields -> the BindFirstPlan plan; the
  typed errors pass through (bind failure dial_failed, exhaustion
  resource_shortage, registry miss unknown_resource)
- BindFirstAcceptLoop: the type-erased accept closure (receives the
  opaque listener; yields (peer, halves) per accept; Err is
  loop-critical)
- ForwardedOpener: the per-accept forwarded-open caller — the call
  plane (call_with_payload on channel 0 + manager.adopt_channel; the
  reverse POC's open_reverse_channel shape)
- make_bindfirst_accept_handler: the accept dispatcher — accept_bi
  first (R-02 telemetry), the idle listen-stream EOF sentinel cancels
  the loop + tears the listener down (select!), per-accept pump_bidi
  tasks tracked by the handler's JoinHandle (awaited on loop exit —
  a forwarded pump never outlives its listener), per-accept failure
  isolation (log + drop; v1 no retry), loop-critical accept errors
  end the loop
- register_tunnel_bindfirst_openable: same spec + tunnel:open scope
  gate (the per-registration establisher posture); the injected
  seams ride BindFirstInjections {bind, accept_loop, open_forwarded}
  (8-arg sketch -> the bundle; clippy too_many_arguments) + the
  optional CF-006 identity witness (witness_establisher wrapper)
- harness: RegistrationMode::BindFirst + wire_bindfirst; the consumer
  hook gained the accept-side identity propagation + a shared serving
  registry (Topology.consumer_registry exposed); the forwarded-op
  serving stub (make_forwarded_stub_handler — correlate by token,
  accept the BiStream, pump_bidi against the correlated halves; the
  opener-side registration lands with tunnels/forwarded-receipt)
- tests: 6 new integration tests (the full flow e2e — the acceptance
  gate incl. the byte round-trip through the whole path, the
  dial-open neither-field projection, EOF teardown, per-accept
  isolation, typed establishment errors, token discipline) + 6 unit
  tests (the mint discipline, the establisher flow at the
  Establishment surface, the witness wrapper)

Verified: cargo test green (118 default; 138 --all-features), clippy
--all-targets -D warnings clean (native + wasm32), fmt clean,
wasm32-unknown-unknown check passes, cargo doc --no-deps clean.
2026-09-19 09:07:28 +00:00
glm-5.3-flash d586a5d2b8 feat: the forwarded op's params + the bind-first plan type (ADR-008 as amended)
- params.rs: OP_TUNNEL_FORWARDED / TUNNEL_FORWARDED_SCOPE; TunnelForwardedParams
  {listen, substrate, peer} (deny-unknown; listen positive via schema minimum 1 +
  a serde pin); tunnel_forwarded_spec() (Sub, External, alk/tunnel ALPN, scope
  tunnel:forwarded, per-substrate if/then peer sub-schema, no establisher);
  TunnelListenReply + parse_listen_reply (the additive bound/listen reply
  projection — unknown reply fields tolerated, malformed ones fail loud)
- error.rs: TunnelError::MalformedReply (the viewer's fail-loud path)
- producer.rs: BindFirstPlan {listener: Arc<dyn Any + Send + Sync>, token: u32}
  — the opaque assembly-typed listener + the correlation token; the mint
  discipline pinned in docs (non-zero-seeded AtomicU32, per-open fresh,
  per-producer unique, wraps acceptable)
- Wire-stable types pin first (the tunnels/params ordering); the establisher +
  accept dispatcher + registration land with tunnels/bindfirst-producer

Verified: cargo test (105 default; 125 --all-features), clippy --all-targets
-D warnings (native + wasm32), fmt --check, wasm32-unknown-unknown check,
cargo doc --no-deps — all clean.
2026-09-19 07:44:28 +00:00
glm-5.3-flash 0789ab62cb feat: the direct op (channels/tunnel/direct, ADR-007) end to end
- params.rs: OP_TUNNEL_DIRECT / TUNNEL_DIRECT_SCOPE constants;
  SubstrateAddr (untagged {host,port} | {path}; also the ADR-008 §3
  peer shape); TunnelDirectParams with render_target();
  tunnel_direct_spec() — Sub-typed, External, alk/tunnel ALPN
  marker, scope ["tunnel:direct"] (never implied by tunnel:open),
  per-substrate if/then target sub-schema (malformed targets fail
  the registry's schema gate as INVALID_INPUT before the
  establisher). 7 unit tests.
- producer.rs: direct_establisher (+ witness variant) — parse,
  render the target to the registry backing-string form, dial via
  the SAME injected DialFn; no registry lookup (unknown_resource
  can never fire); typed errors per ADR-007 §4 minus the registry
  row; CF-006 witness seam. register_tunnel_direct_openable reuses
  the base pump handler (plan-flow unchanged).
- consumer.rs: TunnelSession::open_direct — separate constructor
  (two scopes are two capabilities), plain open_channel (dial
  establishers never bind), identical session/data-planes/teardown.
- tests/producer_direct_op.rs: 15 integration tests — tcp + udp e2e
  round-trips, malformed-target rejection (no phantom session),
  dial-failure pass-through, NOT_FOUND posture, scope separation
  both ways (FORBIDDEN, raw-call + session-level), CF-006 witness
  (raw + session paths), parse backstop, target-rendering pin.
  Harness: Direct registration mode, wire_direct* topologies,
  direct_identity/both_scopes_identity.
- CHANGELOG: Unreleased → Added.

Verified: cargo test green (93 native, 94 --all-features); clippy
-D warnings clean (native + wasm32); fmt clean; wasm32 check passes
(default crate stays wasm-clean); cargo doc clean.

Task: tasks/tunnels/direct-op.md (status: completed)
2026-09-19 04:58:37 +00:00
glm-5.3-flash 52b6252531 fix: taskgraph frontmatter variants — scope medium -> moderate, status done -> completed
tunnels/tunnel-listener + tunnels/forwarded-receipt used `scope: medium`
(not a valid variant — single/narrow/moderate/broad/system), which made
taskgraph silently drop both from the graph (validate failed; topo/
list missed them). tunnels/coverage-weak-spots used `status: done`
(pre-existing; valid variants are pending/in-progress/completed/failed/
blocked).

Verified: taskgraph validate (22 tasks), topo, cycles (none),
parallel, critical all resolve with the full graph.
2026-09-18 14:46:25 +00:00
glm-5.3-flash f315b0e0ca docs+tasks: ADR-008 Amendment 1 (correlation token); decompose the graduation into tasks/tunnels
Verification pass against the landed alkcall 0.8.0 surface surfaced one
design gap: ADR-008 §3 pinned the forwarded op's correlation key as the
listen channel's ID, but (1) the serving-side OpenHandler/establisher
surface never exposes the allocated channel ID, and (2) the in-tree
generic ChannelRelay forwards open-op params untouched (it rewrites
only the reply's channel_id), so a spoke's forwarded open would carry
an id the end opener never allocated. Amended (wire still unconsumed —
alksocks is docs-only — so pre-consumer and legal):

- ADR-008 Amendment 1: the bind-first establisher mints a fresh u32
  correlation token per open — carried in the plan (for the accept
  dispatcher) and contributed as the additive `listen` reply field
  (the same projection carrier `bound` rides); the forwarded op's
  params rename `channel` -> `listen` with token semantics. Tokens
  ride reply fields + params through hubs untouched (relay-transparent
  by construction, alkcall ADR-051). Consumer-minted tokens and a new
  upstream handler surface were considered and rejected (rationale in
  the amendment).
- Spec docs synced: wire.md (the `bound`+`listen` reply section, the
  forwarded params), producer.md (bind-first shape + forwarded op),
  consumer.md (TunnelListener gains `listen()`; correlation registry
  posture), overview.md, OQ ledger (OQ-TN-16 updated),
  tunnels-graduation.md.

Task decomposition (tasks/tunnels/, Phase 2 of the graduation — the
alkcall prerequisites are gone):
- direct-op — params/spec/establisher/open_direct (ADR-007)
- bindfirst-params — forwarded-op spec, TunnelListenReply, the
  BindFirstPlan + token mint (wire types first, the params.md
  ordering precedent)
- bindfirst-producer — bind-first establisher, accept dispatcher,
  register_tunnel_bindfirst_openable
- forwarded-receipt — the opener-side serving op + reply-field reader
- tunnel-listener — the listen-flow consumer session (ADR-008 §4)
- local-bind-halves — the `local` TCP bind + accept-pairs loop,
  unix parity decision, direct-op DialFn reuse pin

Dependency order: direct-op and bindfirst-params are independent
roots; bindfirst-producer -> {bindfirst-params}; forwarded-receipt ->
bindfirst-params; tunnel-listener -> {bindfirst-params,
forwarded-receipt}; local-bind-halves -> bindfirst-producer.

Verification: cargo test 71 passed; clippy --all-targets -D warnings;
fmt --check; wasm32-unknown-unknown check + clippy clean (docs + task
files only — no code change).
2026-09-18 14:26:21 +00:00
glm-5.3-flash c31dd5e100 chore: bump alkcall 0.7.1 -> 0.8.0; mark the review-008 asks landed
alkcall 0.8.0 lands both graduation upstream asks (review 008):
- U-2: the establisher → reply projection (ADR-049 amendment 3,
  Establishment::with_reply_field + open_channel_with_reply) — the
  bind-first 'bound' carrier
- U-1: flavor-form open-op ids in discovery (ADR-047 amendment 3) —
  the direct/forwarded ops reconstruct their marker through discovery
plus the bonus: in-tree ChannelRelay + HubLegTemplate (ADR-051) — the
hub re-produce machinery is upstream now; 'bound' flows end-to-end
per-hop truthful, realizing ADR-008 §6's sentence.

OQ-TN-12 resolves upstream (the composition helper a hub proxy needed
is alkcall's ChannelRelay); spec docs note the asks landed. No
alktunnels API or wire change in this bump — the graduation
implementation (tasks/tunnels) follows.

Verification: cargo test 71 passed (91 all-features), clippy
--all-targets -D warnings, fmt --check, wasm32-unknown-unknown check
+ clippy clean
2026-09-18 13:33:06 +00:00
glm-5.3-flash 0d6b23b7cf docs: spec the graduation — ADR-007 (direct op), ADR-008 (listen metadata + forwarded op)
The alksocks graduation research (tunnels-graduation.md) is decided;
the ADRs are normative now:

- ADR-007: the direct open op — channels/tunnel/direct on the one
  alk/tunnel ALPN, params {substrate, target} (self-describing JSON
  target; ATYP bytes rejected as not schema-describable), scope
  tunnel:direct (never implied by tunnel:open), dial-only.
- ADR-008: listen metadata — bind-first listen establisher (reply
  gains additive 'bound' = BND.ADDR fidelity; the BIND deadlock +
  10s-bound findings dissolve) + the forwarded op
  channels/tunnel/forwarded (accept-as-open, params carry
  {channel, substrate, peer} = reply#2 fidelity). Accept-first stays.
- ADR-001/002/006 amendments: params layout is per-op; flavor-form
  op ids on the one ALPN; per-flavor scopes + the hub re-produce
  policy sentence (the hub's ACL governs the final dial).
- wire/producer/consumer/overview/README updated for the new ops,
  the three establisher shapes, TunnelListener, open_direct.
- OQ-TN-15/16 promoted + resolved; upstream alkcall asks filed at
  alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md
  (flavor-form discovery derivation; the establisher → reply
  projection).

Verification: cargo test (22 passed), clippy -D warnings, fmt --check
(docs-only change; wasm check unaffected)
2026-09-16 08:31:56 +00:00
glm-5.3-flash c86021e6a9 docs: research — graduating proxy surface into alktunnels (direct op + BIND metadata carriers)
Downstream alksocks motivation: CONNECT/BIND/ASSOCIATE need three
additions, all generally useful:

- One wire addition: a direct/dynamic open op on the alk/tunnel ALPN
  (channels/tunnel/direct, scope tunnel:direct) — the ssh
  direct-tcpip capability, additive to ADR-001's resource-not-address
  params. Options weighed; second-op lean (ACL separation at the op,
  per the socks5:bind scope-gate precedent).
- The two BIND metadata asks (bound-addr discovery, peer metadata)
  with carrier options, including the forwarded-tcpip alternative
  (reverse open with peer/target in params) that retires both with
  existing reverse-path machinery.
- Callback→provider mapping: the composed providers the graduation
  gives each alksocks seam (DialFn/EgressFactory/BindAcceptFactory)
  next to the local-socket ones.

Also records the vpn-like endgame positioning (virtual-subnet
translation via dial callback, signing as directory/identity-layer)
and alknodes as the OQ-TN-12 re-produce consumer.

Verification: docs-only change (cargo test/clippy/fmt unaffected).
2026-09-15 14:17:39 +00:00
169 changed files with 14655 additions and 173 deletions

No files matched your search

+257 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+133 -8
View File
@@ -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, &registry, &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, &registry, &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
View File
@@ -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
+89 -10
View File
@@ -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.
+79 -12
View File
@@ -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
+63 -36
View File
@@ -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
View File
@@ -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
View File
@@ -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`
+587
View File
@@ -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)
+321
View File
@@ -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)
+10
View File
@@ -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
+1297
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -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]
+58
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
��bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbLine truncated
View File
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.
+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@
��
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
��
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
4
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{"resource": "postgres-primary", "substrate": "tcp"}
+1
View File
@@ -0,0 +1 @@
{"resource": "postgres-primary", "substrate": "udp"}
+1
View File
@@ -0,0 +1 @@
{"resource": "sock", "substrate": "unix"}
+1
View File
@@ -0,0 +1 @@
{"resource": "r", "substrate": "tcp", "extra": true}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp"}
+1
View File
@@ -0,0 +1 @@
{"resource": "r"}
+1
View File
@@ -0,0 +1 @@
{"resource": "", "substrate": ""}
+1
View File
@@ -0,0 +1 @@
{"resource": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "substrate": "tcp"}
+1
View File
@@ -0,0 +1 @@
{"resource": null, "substrate": "tcp"}
+1
View File
@@ -0,0 +1 @@
{"resource": 1, "substrate": 2}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": {"host": "db.internal", "port": 5432}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "udp", "target": {"host": "10.0.0.7", "port": 53}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "unix", "target": {"path": "/run/x.sock"}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": {"port": 1}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": {"host": "h"}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "unix", "target": {"path": "/p", "host": "h"}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": {"host": "h", "port": 65536}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": {"host": "h", "port": -1}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": [1, 2]}
+1
View File
@@ -0,0 +1 @@
{"substrate": "unix", "target": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "target": {}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp"}
+1
View File
@@ -0,0 +1 @@
{"target": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"listen": 918273, "substrate": "tcp", "peer": {"host": "198.51.100.4", "port": 55231}}
+1
View File
@@ -0,0 +1 @@
{"listen": 918273, "substrate": "unix", "peer": {"path": "/run/x.sock"}}
+1
View File
@@ -0,0 +1 @@
{"listen": 0, "substrate": "tcp", "peer": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"listen": -1, "substrate": "tcp", "peer": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"listen": 1, "substrate": "tcp", "peer": {"host": "h", "port": 1}, "channel": 7}
+1
View File
@@ -0,0 +1 @@
{"listen": 4294967296, "substrate": "tcp", "peer": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"listen": "token", "substrate": "tcp", "peer": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"substrate": "tcp", "peer": {"host": "h", "port": 1}}
+1
View File
@@ -0,0 +1 @@
{"listen": 1, "substrate": "tcp"}
+1
View File
@@ -0,0 +1 @@
{"listen": 1, "substrate": "unix", "peer": {"path": "/p", "host": "h"}}
+1
View File
@@ -0,0 +1 @@
{"channel_id": 7, "bound": {"host": "203.0.113.9", "port": 42113}, "listen": 918273}
+1
View File
@@ -0,0 +1 @@
{"channel_id": 7}
+1
View File
@@ -0,0 +1 @@
{"channel_id": 0}
+1
View File
@@ -0,0 +1 @@
{"channel_id": 7, "bound": {"path": "/run/x.sock"}, "listen": 5, "future_field": {"n": true}}
+1
View File
@@ -0,0 +1 @@
{"channel_id": 7, "bound": {"port": 1}}
+1
View File
@@ -0,0 +1 @@
{"channel_id": 7, "listen": 0}
+1
View File
@@ -0,0 +1 @@
{"channel_id": 7, "listen": "token"}
+1
View File
@@ -0,0 +1 @@
{"bound": {"path": "/p"}}
+1
View File
@@ -0,0 +1 @@
{"channel_id": -7}
+1
View File
@@ -0,0 +1 @@
{"channel_id": 7.5}
+1
View File
@@ -0,0 +1 @@
{"channel_id": 7, "bound": 5}
+1
View File
@@ -0,0 +1 @@
{"channel_id": 7, "listen": 4294967296}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{"code": "channel:open_failed", "message": "establishment failed", "details": {"reason": "dial_failed"}}
+1
View File
@@ -0,0 +1 @@
{"code": "channel:open_failed", "details": {"reason": "unknown_resource"}}
+1
View File
@@ -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