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)
This commit is contained in:
1 parent
c86021e6a9
commit
0d6b23b7cf
12 files changed
+1054
-112
No files matched your search
+46
-28
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: implemented
|
||||
last_updated: 2026-09-10
|
||||
status: draft
|
||||
last_updated: 2026-09-16 (the graduation ADRs 007/008 + the amendments)
|
||||
---
|
||||
|
||||
# alktunnels — Architecture
|
||||
@@ -18,15 +18,15 @@ raw byte/datagram transport, not terminal sessions).
|
||||
| Document | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| [overview.md](overview.md) | draft | Crate purpose, the resource model in brief, dependencies, ALPN, feature gates, module map |
|
||||
| [wire.md](wire.md) | draft | The wire format: open-op params JSON (ADR-001), the data-plane codec (raw pass-through for stream substrates; mandatory `[len: u16 BE]` framing for UDP — ADR-003), sentinels, half-close semantics |
|
||||
| [producer.md](producer.md) | draft | The producer half: the `channels/tunnel/sub` open op, the establisher (dial + listen variants), the pump handler, registration, scope gating |
|
||||
| [consumer.md](consumer.md) | draft | The consumer half: `TunnelSession` (stream + datagram variants), the reverse-flow initiator, teardown ownership (ADR-005) |
|
||||
| [wire.md](wire.md) | draft | The wire format: open-op params JSON (ADR-001; the direct op's `{substrate, target}` — ADR-007), the `bound` reply field + the forwarded op (ADR-008), the data-plane codec (raw pass-through for stream substrates; mandatory `[len: u16 BE]` framing for UDP — ADR-003), sentinels, half-close semantics |
|
||||
| [producer.md](producer.md) | draft | The producer half: the `channels/tunnel/sub` + `channels/tunnel/direct` open ops, three establisher shapes (dial, accept-first listen, bind-first listen), the pump handler, the forwarded-op opener side, registration, scope gating |
|
||||
| [consumer.md](consumer.md) | draft | The consumer half: `TunnelSession` (stream + datagram variants, `open`/`open_direct`), `TunnelListener` (the listen flow), the reverse-flow initiator, teardown ownership (ADR-005/008) |
|
||||
| [bast.md](bast.md) | draft | The BAST (Binary Abstract Syntax Tree) document for the data-plane's binary framing (the UDP `[len: u16 BE]` codec — ADR-003); the stream pass-through has no binary framing to describe |
|
||||
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-TN-01..14; the Phase 0 ledger promoted) |
|
||||
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-TN-01..16; the Phase 0 ledger promoted) |
|
||||
|
||||
## Applicable ADRs
|
||||
|
||||
alktunnels-native ADRs (001..005). Upstream ADRs (alkcall/alknet) are
|
||||
alktunnels-native ADRs (001..008). Upstream ADRs (alkcall/alknet) are
|
||||
referenced by their own numbers and are NOT ported — the alkcall
|
||||
originals at `/workspace/@alkdev/alkcall/docs/architecture/decisions/`
|
||||
remain authoritative for the shared surfaces (establishment, channels
|
||||
@@ -34,22 +34,28 @@ wire format, `pump_bidi`, identity seam).
|
||||
|
||||
| ADR | Title | Status |
|
||||
|-----|-------|--------|
|
||||
| [001](decisions/001-open-params-layout.md) | Open-Op Params Layout: Resource + Substrate | Accepted |
|
||||
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN (Option A) | Accepted |
|
||||
| [001](decisions/001-open-params-layout.md) | Open-Op Params Layout: Resource + Substrate | Accepted; amended (params layout is per-op — the direct op's params ride ADR-007) |
|
||||
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN (Option A) | Accepted; amended (flavor-form op ids: `…/sub`, `…/direct`, `…/forwarded` on the one ALPN) |
|
||||
| [003](decisions/003-codec-and-udp-framing.md) | Data-Plane Codec: Raw Pass-Through (Stream) / Mandatory Length Framing (UDP) | Accepted |
|
||||
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend Trait — Halves Functions at the Assembly Layer | Accepted |
|
||||
| [005](decisions/005-consumer-session-owns-teardown.md) | The Consumer Session Type Owns Teardown | Accepted |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-Control Posture — the Open Gate Is the Security Boundary | Accepted |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-Control Posture — the Open Gate Is the Security Boundary | Accepted; amended (`tunnel:direct`/`tunnel:forwarded` scopes; the hub re-produce policy sentence) |
|
||||
| [007](decisions/007-direct-open-op.md) | The Direct Open Op — Dynamic-Target Egress | Accepted |
|
||||
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen Metadata — Bind-First Establisher and the Forwarded Op | Accepted |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **A tunnel is a resource, not an address.** `params` identify a
|
||||
produced resource + substrate discriminator; the producer owns the
|
||||
backing (local port, docker container, in-process service, unix
|
||||
socket). No URL-style general addressing in the wire — rich
|
||||
addressing enters only through `-D`/dynamic composition, which
|
||||
speaks its protocol inside the tunnel payload. See
|
||||
[ADR-001](decisions/001-open-params-layout.md) and
|
||||
1. **A tunnel is a resource, not an address — with one additive
|
||||
exception.** Base params identify a produced resource + substrate
|
||||
discriminator; the producer owns the backing (local port, docker
|
||||
container, in-process service, unix socket). Rich addressing
|
||||
enters through `-D`/dynamic composition, which speaks its protocol
|
||||
inside the tunnel payload. The exception (ADR-007): the direct op
|
||||
lets a consumer ask the producer to dial an arbitrary target — a
|
||||
different capability on its own op and scope (`tunnel:direct`),
|
||||
never implied by `tunnel:open`. See
|
||||
[ADR-001](decisions/001-open-params-layout.md),
|
||||
[ADR-007](decisions/007-direct-open-op.md), and
|
||||
[wire.md](wire.md).
|
||||
|
||||
2. **Role follows the resource; connection direction is independent.**
|
||||
@@ -57,9 +63,11 @@ wire format, `pump_bidi`, identity seam).
|
||||
channels); whoever wants the bytes is the consumer. `-L` and `-R`
|
||||
are the same open op with the entry point on different machines —
|
||||
no protocol-level direction (the hub-owns-the-connection model,
|
||||
OQ-TN-03). Validated by the reverse-flow POC: the worker serves on
|
||||
the connect side; the hub initiates per accept. See
|
||||
[producer.md](producer.md) and [consumer.md](consumer.md).
|
||||
OQ-TN-03). The composed `-R` flow rides the bind-first establisher
|
||||
+ the forwarded op (ADR-008). Validated by the reverse-flow POC:
|
||||
the worker serves on the connect side; the hub initiates per
|
||||
accept. See [producer.md](producer.md) and
|
||||
[consumer.md](consumer.md).
|
||||
|
||||
3. **Raw pass-through for stream substrates; mandatory length framing
|
||||
for UDP.** A tunnel has one data stream per direction — no
|
||||
@@ -100,14 +108,19 @@ wire format, `pump_bidi`, identity seam).
|
||||
second demux/mux (AGENTS.md convention 10).
|
||||
|
||||
8. **Security posture: the open gate is the boundary.** Scope-gated
|
||||
opens (`TUNNEL_OPEN_SCOPE = "tunnel:open"`, stable once published
|
||||
— ADR-006), alkcall's op-level ACL via `register_openable`; caller
|
||||
identity resolves in the 0.7.0 precedence order (token >
|
||||
`ServingConfig.identity` > transport); the establisher/pump
|
||||
handler see the per-call opener identity. No target allowlists, no
|
||||
tunnel-specific policy machinery in v1. See
|
||||
[ADR-006](decisions/006-access-control-posture.md) and
|
||||
[producer.md](producer.md).
|
||||
opens (`TUNNEL_OPEN_SCOPE = "tunnel:open"`,
|
||||
`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`,
|
||||
`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"` — each stable once
|
||||
published; ADR-006 + Amendment 1), alkcall's op-level ACL via
|
||||
`register_openable`; caller identity resolves in the 0.7.0
|
||||
precedence order (token > `ServingConfig.identity` > transport);
|
||||
the establisher/pump handler see the per-call opener identity. No
|
||||
target allowlists in the protocol (an assembly establisher MAY
|
||||
enforce them — policy, not protocol). Hub re-produce: the hub's
|
||||
ACL governs the final dial (the hub is the network boundary). See
|
||||
[ADR-006](decisions/006-access-control-posture.md),
|
||||
[ADR-007](decisions/007-direct-open-op.md), and
|
||||
[producer.md](producer.md).
|
||||
|
||||
9. **Wasm-clean default crate.** The default crate (protocol only)
|
||||
compiles to `wasm32-unknown-unknown`; socket/platform I/O lives in
|
||||
@@ -140,6 +153,11 @@ Questions section.
|
||||
lifetime contract, R-03 = `pump_bidi`) and
|
||||
`/workspace/@alkdev/alkcall/docs/reviews/consumer-findings-ledger.md`
|
||||
(CF-005/CF-006 = the identity seam, resolved in 0.7.0)
|
||||
- The graduation research (the two new ADRs implement it):
|
||||
`docs/research/tunnels-graduation.md`; the pending upstream alkcall
|
||||
asks are filed at `/workspace/@alkdev/alkcall/docs/reviews/
|
||||
008-tunnels-graduation-upstream-review.md` (flavor-form discovery
|
||||
derivation; the establisher → reply `bound` projection)
|
||||
- Sibling precedent: alktty (`/workspace/@alkdev/alktty`) — the
|
||||
producer/consumer-on-channels shape, wasm-clean default,
|
||||
feature-gated local backend, BAST convention
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
---
|
||||
status: implemented
|
||||
last_updated: 2026-09-10
|
||||
status: draft
|
||||
last_updated: 2026-09-16 (`open_direct`, `TunnelListener`, the forwarded-op serving side — ADR-007/008)
|
||||
---
|
||||
|
||||
# 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,47 @@ 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 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).
|
||||
- **`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).
|
||||
- **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). The opener-side assembly correlates the
|
||||
open (via the `channel` param) to the waiting session, adopts the
|
||||
allocated channel (`TunnelSession::adopt`), and pumps against the
|
||||
local BIND client's halves. The correlation registry is
|
||||
assembly-side (alksocks') bookkeeping.
|
||||
- **`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 +211,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 +224,9 @@ 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: `alkcall/docs/reviews/
|
||||
008-tunnels-graduation-upstream-review.md`
|
||||
- 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,192 @@
|
||||
# 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.
|
||||
|
||||
### 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 (`oneOf` on the sibling `substrate` value, validated at
|
||||
establisher time — see 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.
|
||||
- **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.
|
||||
|
||||
## 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,219 @@
|
||||
# ADR-008: Listen Metadata — Bind-First Establisher and the Forwarded Op
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-16)
|
||||
|
||||
## 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 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.
|
||||
|
||||
### 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`.
|
||||
- `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
|
||||
{
|
||||
"channel": 7,
|
||||
"substrate": "tcp",
|
||||
"peer": { "host": "198.51.100.4", "port": 55231 }
|
||||
}
|
||||
```
|
||||
|
||||
- **`channel`** (u32, required) — the listen channel's ID: the
|
||||
correlation key. The opener knows which of its open listener
|
||||
sessions the accept belongs to without a registry op or an
|
||||
extra wire field; terminate-and-re-produce hubs re-map the id at
|
||||
their re-produce hop naturally (the hub's re-produced forwarded
|
||||
open carries the hub's channel id).
|
||||
- **`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 `channel`) to the waiting
|
||||
session, accept the `BiStream`, pump against the local BIND client's
|
||||
halves. The correlation registry is assembly-side (alksocks')
|
||||
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`
|
||||
let bound = listener.bound(); // the resolved address
|
||||
// ... per forwarded accept (opener side, served op):
|
||||
// correlate by `channel`, 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.
|
||||
|
||||
### 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` reply field,
|
||||
the forwarded op id, its params, and `tunnel:forwarded` are one-way
|
||||
doors. The `bound` field is additive (absent = old-producer
|
||||
compatible); the forwarded op is additive (old openers reject loudly).
|
||||
- **Upstream coordination before the first consumer:** the
|
||||
`bound`-projection ask (ADR-049 amendment 3) and the flavor-form
|
||||
discovery derivation (ADR-047 amendment) land in alkcall first —
|
||||
the review records both. The bind-first establisher itself is this
|
||||
crate's work and does not wait on upstream (§2's `bound` is the only
|
||||
upstream-gated piece).
|
||||
- **`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`) and re-opens forwarded
|
||||
channels with its own channel ids — the metadata is per-hop
|
||||
truthful, matching alkcall ADR-042's terminate-and-re-produce
|
||||
posture.
|
||||
- **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)
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: implemented
|
||||
last_updated: 2026-09-10
|
||||
status: draft
|
||||
last_updated: 2026-09-16 (OQ-TN-15/16 promoted + resolved by ADR-007/008)
|
||||
---
|
||||
|
||||
# alktunnels — Open Questions
|
||||
@@ -240,4 +240,52 @@ 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 is `/sub`-shaped) — filed in
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
|
||||
(ask #1); the runtime was never blocked (explicit ALPN marker +
|
||||
explicit ALPN argument on `open_channel`).
|
||||
- **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). 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 an
|
||||
upstream alkcall ask — the establisher → reply projection, filed as
|
||||
the review's ask #2; ADR-049's `#[non_exhaustive]` anticipates it),
|
||||
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 `{channel, substrate,
|
||||
peer}` in params (ask #2's carrier; the `TargetHandle.peer` option
|
||||
is superseded). The accept-first listen shape stays for
|
||||
accept-queue-backed resources.
|
||||
- **Cross-references**: ADR-002 Amendment 1 (the flavor-form op id),
|
||||
ADR-006 Amendment 1 (`tunnel:forwarded`), ADR-005 (teardown
|
||||
ownership extended to `TunnelListener`), producer.md,
|
||||
consumer.md, wire.md
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: implemented
|
||||
last_updated: 2026-09-10
|
||||
status: draft
|
||||
last_updated: 2026-09-16 (the graduation: the direct op, listen metadata, the forwarded op — ADR-007/008)
|
||||
---
|
||||
|
||||
# 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,16 @@ 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"` in the reply
|
||||
(ADR-008 §2 — the resolved address).
|
||||
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,7 +101,8 @@ 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
|
||||
|
||||
@@ -122,14 +136,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 {channel, substrate, peer} (ADR-008);
|
||||
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 +166,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), `channels/tunnel/forwarded` accept-as-open (params carry `{channel, substrate, peer}` = reply#2 fidelity) — the `forwarded-tcpip` shape |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -155,10 +176,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
|
||||
|
||||
|
||||
+175
-22
@@ -1,22 +1,28 @@
|
||||
---
|
||||
status: implemented
|
||||
last_updated: 2026-09-10
|
||||
status: draft
|
||||
last_updated: 2026-09-16 (the direct op, the bind-first establisher, the forwarded op — ADR-007/008)
|
||||
---
|
||||
|
||||
# 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,48 @@ 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 is an upstream alkcall ask (ADR-049
|
||||
amendment 3; `Establishment` gains optional reply fields, the
|
||||
`channel_id` key wrapper-reserved — see
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`,
|
||||
ask #2). Until upstream lands, the field is simply absent.
|
||||
- **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 +208,45 @@ 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 `{channel, substrate, peer}`
|
||||
— the listen channel's ID (correlation), the substrate, 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 `channel`) 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 +261,25 @@ 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 +287,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 +325,19 @@ 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: `alkcall/docs/reviews/
|
||||
008-tunnels-graduation-upstream-review.md` (flavor-form discovery
|
||||
derivation; the establisher → reply `bound` projection)
|
||||
+116
-7
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: implemented
|
||||
last_updated: 2026-09-10
|
||||
status: draft
|
||||
last_updated: 2026-09-16 (the graduation wire surface: direct + forwarded ops, `bound` reply field — ADR-007/008)
|
||||
---
|
||||
|
||||
# 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,101 @@ 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, validated at establisher time. Malformed targets are
|
||||
the `invalid_input`-class failure; **`unknown_resource` can never
|
||||
fire on the direct op** (no registry lookup).
|
||||
|
||||
### 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` Field
|
||||
|
||||
A bind-first listen open (ADR-008 §1, served by `…/sub` with the
|
||||
bind-first establisher) MAY add an optional, additive reply field:
|
||||
|
||||
```json
|
||||
{ "channel_id": 7, "bound": { "host": "203.0.113.9", "port": 42113 } }
|
||||
```
|
||||
|
||||
- Substrate-shaped like the direct op's target (`{host, port}` /
|
||||
`{path}`); the REAL OS-chosen address (port `0` resolved).
|
||||
- Present only when the establisher supplies it; absent for dial
|
||||
establishers and accept-first listen opens. Old consumers ignore
|
||||
unknown reply fields (plain JSON) — wire-additive.
|
||||
- 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
|
||||
{
|
||||
"channel": 7,
|
||||
"substrate": "tcp",
|
||||
"peer": { "host": "198.51.100.4", "port": 55231 }
|
||||
}
|
||||
```
|
||||
|
||||
- `channel`: integer > 0, required — the listen channel's ID (the
|
||||
correlation key; hubs re-map it at their re-produce hop).
|
||||
- `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).
|
||||
|
||||
### 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 +260,27 @@ 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` field + 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:
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
|
||||
@@ -1,10 +1,42 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-09-15 (initial draft — the alktunnels graduation discussion)
|
||||
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 `{channel, substrate, peer}` params (ask #2). 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`.
|
||||
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user