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).
This commit is contained in:
glm-5.3-flash committed 2026-09-15 14:17:39 +00:00
1 parent d3a8006b1f
commit c86021e6a9
1 file changed
+284
+284
View File
@@ -0,0 +1,284 @@
---
status: draft
last_updated: 2026-09-15 (initial draft — the alktunnels graduation discussion)
---
# alksocks research — graduating proxy surface into alktunnels
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).