Port the call + channels architecture documentation from the alknet mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045. Renumbering map (alknet -> alkcall): Core: 001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012 Call: 005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030 Shared: 003,009,013 -> 031-033 Channels: 071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045 3 superseded/reversed ADRs kept for historical trail: - ADR-013 (irpc foundation, superseded by ADR-014) - ADR-023 (peer-scoped filtering, superseded by ADR-024) - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only) Ported docs (11 spec files + README + open-questions): - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md - README.md (index with doc table, ADR table grouped by category, key principles) - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap) Cross-reference rewriting: - All ADR-NNN references rewritten single-pass (no chaining bug) - Markdown link paths fixed - Title lines aligned with filenames - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note The open-questions.md includes OQ-22 (new): the call protocol pub/sub gap — subscribe exists but pub does not, needed for channels channel/resources/subscribe fan-out. This is the next ADR to write (alkcall ADR-046).
181 lines
9.0 KiB
Markdown
181 lines
9.0 KiB
Markdown
# ADR-042: Hub Relay — Translate, Not Transparently Forward
|
||
|
||
## Status
|
||
|
||
Accepted
|
||
|
||
## Context
|
||
|
||
The hub is the architectural role (ADR-024, ADR-034) that bridges peers and
|
||
browsers. With channels, the hub holds one channels connection per leg
|
||
(browser↔hub, hub↔spoke) and relays channels between them. The phase-0
|
||
research (`docs/research/alknet-channels/phase-0-findings.md` §OQ-CH-11,
|
||
§The hub relay) identified the key question: does the hub *translate*
|
||
`channel/open` (terminate channel 0 on both legs, re-issue the open on the
|
||
spoke leg) or *transparently forward* (pass the call operation through
|
||
unchanged)?
|
||
|
||
This is the most under-specified part of the research for something that is
|
||
the *primary motivation* for the channels crate (§Hub Motivation: the
|
||
multi-transport collapse). The research said "Phase 1 must specify whether
|
||
the hub translates or transparently forwards, and how the `channel_id`
|
||
mapping is maintained."
|
||
|
||
The answer is derivable from the existing machinery:
|
||
- The hub terminates channel 0 on both legs (it runs its own `CallAdapter`
|
||
per leg — ADR-036).
|
||
- The hub's `CallAdapter` receives the browser's `channel/open` as a call
|
||
operation, runs `AccessControl::check` with the browser's identity, then
|
||
forwards via `from_call` to the spoke (the hub as caller, the browser as
|
||
`forwarded_for` — ADR-026 §3).
|
||
- The spoke allocates its `channel_id` and returns it; the hub maps
|
||
browser-id ↔ spoke-id.
|
||
|
||
Transparent forwarding (passing the `channel/open` call operation through
|
||
without the hub's `CallAdapter` terminating it) would bypass the hub's
|
||
`AccessControl::check` and the `forwarded_for` auth chain — the hub would
|
||
not authenticate the open, and the spoke would see the browser as the direct
|
||
caller (not the hub), breaking the ADR-026/ADR-011 auth model. Translation
|
||
is the only option that preserves the auth model.
|
||
|
||
## Decision
|
||
|
||
### The hub translates, not transparently forwards
|
||
|
||
The hub's relay has two layers:
|
||
|
||
1. **Call-protocol layer (channel 0): translate.** The hub terminates
|
||
channel 0 on both legs. A `channel/open` from the browser is received by
|
||
the hub's `CallAdapter`, which:
|
||
1. Runs `AccessControl::check` on `channel/open` with the browser's
|
||
identity (bearer token resolved per ADR-034). If denied →
|
||
`channel:forbidden` to the browser.
|
||
2. Issues a *new* `channel/open` on the spoke's channel 0 via `from_call`,
|
||
with the hub as caller and the browser as `forwarded_for` (ADR-026
|
||
§3). The spoke's `AccessControl::check` sees the hub as the direct
|
||
peer (authorized per ADR-011) and the browser as `forwarded_for`.
|
||
3. The spoke allocates its `channel_id` and returns it.
|
||
4. The hub opens a matching channel on the browser's side (the hub is now
|
||
the *responder* for the browser leg, *initiator* for the spoke leg)
|
||
and records the `channel_id` mapping: `browser_id ↔ spoke_id`.
|
||
|
||
2. **Data-channel layer: byte-forward with `channel_id` rewrite.** Once the
|
||
mapping is established, the relay reads chunks for `browser_id` off the
|
||
browser's channels connection, rewrites the `channel_id` field to
|
||
`spoke_id`, and writes them onto the spoke's channels connection — and
|
||
vice versa. The relay does not parse the payload; it does not know if the
|
||
bytes are TTY chunks, SSH frames, or tunnel data. The channels layer on
|
||
each end does the chunk↔stream conversion; the relay just moves bytes
|
||
between two `AsyncRead + AsyncWrite` pairs with a 4-byte header rewrite.
|
||
|
||
### `channel_id` mapping
|
||
|
||
The hub maintains a `HashMap<channel_id, channel_id>` per (browser, spoke)
|
||
pair — the relay map. On `channel/open` (translated), the mapping is
|
||
inserted. On `channel/close` (translated the same way), the mapping is
|
||
removed. The relay task per channel reads the map to determine the rewrite
|
||
target.
|
||
|
||
`channel/control` operations on channel 0 carry `channel_id` in their JSON
|
||
payload (not in the chunk header). The hub's `CallAdapter` translates these
|
||
too: the browser's `channel/control` for `browser_id` is re-issued on the
|
||
spoke leg with `spoke_id` in the payload. The relay does not touch
|
||
`channel/control` — it's a call operation, translated by the hub's
|
||
`CallAdapter`, not byte-forwarded.
|
||
|
||
### What the hub runs
|
||
|
||
| Leg | What the hub runs |
|
||
|-----|-------------------|
|
||
| Browser leg | `ChannelsAdapter` (the relay's read/demux) + `CallAdapter` (channel 0, for the hub's own ops + translating the browser's ops) |
|
||
| Spoke leg | `ChannelsAdapter` + `CallAdapter` (same) |
|
||
| Relay | Per-channel byte-forward tasks with `channel_id` rewrite |
|
||
|
||
The hub never runs a handler for `alknet/tty`, `alknet/ssh`, or
|
||
`alknet/tunnel`. It runs `alknet/channels` (the relay) and `alknet/call`
|
||
(for its own hub-level operations + translation). The endpoints at each end
|
||
do the protocol work.
|
||
|
||
### What the hub still owns (unchanged from phase-0 §What the hub does still own)
|
||
|
||
- **Routing:** which spoke serves `container:abc123`? The hub's resource
|
||
registry / ownership store (ADR-011), queried via call operations on
|
||
channel 0. Channels doesn't touch this.
|
||
- **ACL at the hub:** does this browser's identity have `channel:open` scope
|
||
for `alknet/ssh` to `spoke-X`? `AccessControl::check` on `channel/open`,
|
||
run by the hub's `CallAdapter` before it forwards. Channels doesn't touch
|
||
this.
|
||
- **Relay lifecycle:** when a browser disconnects, the hub tears down the
|
||
spoke-side channels (and vice versa). `channel/close` on each channel, or
|
||
a transport-level close the channels layer observes (REQ-CH-02).
|
||
|
||
### Scope note: this is a hub-crate concern, not a channels-crate concern
|
||
|
||
This ADR defines the relay *contract* (translate channel 0, byte-forward
|
||
data channels with ID rewrite) so the channels crate's `ChannelManager`
|
||
exposes the interface the relay needs (`open_channel_stream(channel_id)
|
||
-> BiStream` for the byte-forward pumps). The relay *implementation*
|
||
lives in `alknet-hub` (or a downstream hub like alkapi), not in
|
||
`alknet-channels`. The channels crate is ALPN-blind and does not know it
|
||
is being relayed. The `channel_id` rewrite is a 4-byte field rewrite
|
||
within the 8-byte header (per ADR-035); the relay does not parse the
|
||
payload.
|
||
|
||
## Consequences
|
||
|
||
**Positive:**
|
||
- The auth model reuses cleanly: the hub's `AccessControl::check` +
|
||
`forwarded_for` (ADR-026) is the existing machinery, not a new one. The
|
||
spoke sees the hub as caller, the browser as `forwarded_for` — the
|
||
kernel/user-land + forwarded-for model from ADR-011.
|
||
- The relay is one pump function per channel, not per (protocol × transport)
|
||
cell. The hub's complexity is O(channels), not O(protocols × transports ×
|
||
spokes).
|
||
- The hub never runs protocol-specific handlers — it doesn't parse TTY
|
||
chunks, SSH frames, or tunnel data. It moves bytes and translates call
|
||
operations.
|
||
- `channel/resources/subscribe` (ADR-037) gives the hub a live view of each
|
||
spoke's resources, which the hub aggregates and exposes to the browser.
|
||
|
||
**Negative:**
|
||
- The hub maintains a `channel_id` mapping per (browser, spoke) pair. This
|
||
is per-channel state, not per-connection — a hub with many concurrent
|
||
browser sessions each with multiple channels has a non-trivial map. The
|
||
map is `HashMap<u32, u32>` per pair — cheap per entry, but the entry count
|
||
is (browsers × channels-per-browser). Bounded by `max_channels` (ADR-040)
|
||
per connection.
|
||
- The translate path adds one `channel/open` round-trip per relayed channel
|
||
(browser→hub, hub→spoke). This is the same cost as any hub-relayed call
|
||
operation and is not avoidable without transparent forwarding, which
|
||
breaks the auth model.
|
||
- `channel/control` translation requires the hub's `CallAdapter` to rewrite
|
||
`channel_id` in the JSON payload. This is a small but real translation
|
||
step — the hub is not a pure byte relay for channel 0.
|
||
|
||
## Door type
|
||
|
||
**One-way.** The translate-vs-forward decision is structural: transparent
|
||
forwarding would bypass the hub's `AccessControl::check` and the
|
||
`forwarded_for` chain, breaking the auth model. Reversing to transparent
|
||
forwarding after deployments exist would require re-architecting the hub's
|
||
auth path. The `channel_id` mapping strategy (`HashMap` per pair) is two-way
|
||
— an implementation detail that can change without breaking the contract.
|
||
|
||
## References
|
||
|
||
- ADR-024: peer-graph routing model (the hub's role)
|
||
- ADR-026: forwarded-for identity (the auth chain the translate path uses)
|
||
- ADR-034: outgoing-only X.509 and the three peer roles (browser identity
|
||
resolution)
|
||
- ADR-011: dynamic resource ownership (the ownership store the hub queries)
|
||
- ADR-036: channel 0 is pre-negotiated `alknet/call` (what the hub
|
||
terminates on each leg)
|
||
- ADR-037: channel lifecycle operations (what the hub translates)
|
||
- ADR-039: ChannelsAdapter and ChannelManager (the interface the relay uses)
|
||
- ADR-035: channels pure channel multiplexing (the 8-byte header the relay
|
||
reads/writes; the 4-byte `channel_id` rewrite; the `BiStream`-yielding
|
||
`open_channel_stream` interface)
|
||
- `docs/research/alknet-channels/phase-0-findings.md` §Hub Motivation,
|
||
§The hub relay, §OQ-CH-11
|
||
- `docs/architecture/crates/hub/README.md` — the hub crate (the relay
|
||
implementation's home) |