docs(arch): rewrite hub README for multi-transport + channels substrate; add OQ-58 (worker registration)
The hub README predates channels (2026-07-09) and was built on the call-protocol-directly-over-QUIC model: 'One QUIC connection per peer carrying the call protocol,' CallClient::connect as the dial path, QUIC as the only transport. The channels crate replaced that substrate (ADR-071/079/080), and the hub's primary use case (worker provisioning on docker/vast.ai/runpod) requires TCP+TLS for registration and QUIC or TCP+TLS for the ongoing session — coexisting on one hub, not as alternatives. Rewrite: - Multi-transport stated as decided: 'A hub MUST support TCP+TLS and QUIC endpoints simultaneously.' TCP+TLS accept loop (ADR-010 Am. 1) lives in the hub, shares the HandlerRegistry with the quinn endpoint. - Channels substrate: the hub uses ChannelsAdapter/ChannelClient (from_connection primary, connect_quic convenience — ADR-080), not CallClient/CallAdapter directly. One channels connection per peer; CallAdapter runs on channel 0 (ADR-072). - Transport-agnostic dial/accept: dial_worker_connection takes a Connection (one-way door); connect_quic_worker is a two-way-door convenience. supervise_worker takes a dial closure, not a SocketAddr. - Identity over transports: fingerprint path (QUIC+raw-key, X.509 client cert) via resolve_from_fingerprint; bearer-token path (TCP+TLS no client cert, WebTransport, WebSocket) via resolve_from_token on the call first frame. Both resolve to the same PeerEntry (ADR-030/034). - Worker registration flow (6 steps): provision → token → download → key gen → HTTP POST over TCP+TLS → channels connect. Step 4 is HTTP on HttpAdapter; step 6 is channels over QUIC or TCP+TLS. The hub creates a mixed-fingerprint PeerEntry (ADR-034 §3) at registration. - OQ-58: worker registration flow — enrollment-token model, endpoint shape, register_worker API. Open (decision-ready, not blocked), one-way door, high priority. - ADR-081: fix stale references to channels-hub/channels-worker sub-crates (the ADR's Decision says they don't exist; the References section said they did). Review (architecture-reviewer): 3 critical (ADR-081 stale refs; bearer-token extraction flow; assembly example QUIC-only + unspecced into_connection), 7 warnings (registration 'or'→'both', channel/control translation, OQ-52 interim, HubError variant naming, RegistrationError definition, X.509-client-cert prose, adapter ownership phrasing), 5 suggestions. All criticals and warnings #4/#7/#8 addressed; #5/#6/#9/#10 addressed; #11-15 noted as optional.
This commit is contained in:
1 parent
73c621bf21
commit
a26401aadd
5 files changed
+568
-222
No files matched your search
@@ -171,7 +171,7 @@ adapter location map is now consistent: all HTTP-backed adapters
|
||||
| [crates/vault/encryption.md](crates/vault/encryption.md) | stable | AES-256-GCM, EncryptedData, key versioning, salt (Phase B reserved) |
|
||||
| [crates/vault/service.md](crates/vault/service.md) | stable | VaultServiceHandle lifecycle, direct dispatch, cache, error model |
|
||||
| [crates/vault/protocol.md](crates/vault/protocol.md) | stable | DerivedKey redaction, KeyType, serialization behavior |
|
||||
| [crates/hub/README.md](crates/hub/README.md) | draft | alknet-hub crate — aggregated peer env, connection lifecycle, worker supervision, service discovery |
|
||||
| [crates/hub/README.md](crates/hub/README.md) | draft | alknet-hub crate — multi-transport endpoint (TCP+TLS + QUIC), channels substrate (ADR-079 relay), worker registration flow (OQ-58), identity over transports, aggregated peer env, connection lifecycle, service discovery |
|
||||
| [crates/channels/README.md](crates/channels/README.md) | draft | alknet-channels crate — multiplexing proxy, 9-byte chunk format, N channels over one transport stream |
|
||||
| [crates/channels/overview.md](crates/channels/overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, transport agnosticism, WASM, relationship to existing crates |
|
||||
| [crates/channels/channels-wire.md](crates/channels/channels-wire.md) | draft | 9-byte chunk format, stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
|
||||
|
||||
@@ -1,61 +1,134 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-09
|
||||
last_updated: 2026-07-13
|
||||
---
|
||||
|
||||
# alknet-hub
|
||||
|
||||
The hub pattern as a reusable crate: peer lifecycle management, connection
|
||||
supervision, operation aggregation, and service discovery across connected
|
||||
peers. One QUIC connection per peer carrying the call protocol.
|
||||
The hub pattern as a reusable crate: multi-transport endpoint that
|
||||
accepts workers and browsers over TCP+TLS and QUIC, relays channels
|
||||
between legs (ADR-079), manages peer lifecycle, aggregates operations,
|
||||
and exposes service discovery. One channels connection per peer;
|
||||
multiple endpoint types coexisting.
|
||||
|
||||
## What
|
||||
|
||||
`alknet-hub` is a thin crate that provides the head/worker (hub-spoke) pattern
|
||||
as a reusable library. It depends on `alknet-call` and `alknet-core`. It does
|
||||
not introduce new types — it wires the existing `PeerCompositeEnv`,
|
||||
`CallClient`, `CallAdapter`, `from_call`, and `Dispatcher` types into a
|
||||
coherent hub runtime.
|
||||
`alknet-hub` is the crate that wires the hub role (ADR-029, ADR-034,
|
||||
ADR-079) into a reusable library. A hub is the central node in a
|
||||
hub-and-spoke (head/worker) topology: it accepts inbound connections from
|
||||
workers and browsers, relays channels between legs, aggregates the
|
||||
workers' operations into a shared environment, and serves the discovery
|
||||
API. It depends on `alknet-channels` (the substrate), `alknet-call`
|
||||
(the protocol), and `alknet-core`. It does not introduce new protocol
|
||||
types — it wires the existing `ChannelsAdapter`, `ChannelClient`,
|
||||
`CallAdapter`, `PeerCompositeEnv`, `from_call`, and `Dispatcher` types
|
||||
into a coherent hub runtime.
|
||||
|
||||
### The hub is multi-transport
|
||||
|
||||
A hub **must** support TCP+TLS and QUIC endpoints simultaneously. This is
|
||||
not a convenience — it is the structure of the primary use case. A hub
|
||||
that provisions a worker (in a container, on vast.ai, on runpod) uses
|
||||
TCP+TLS for registration and QUIC (or another transport) for the ongoing
|
||||
channels session. The hub's HTTP endpoint (registration, browser access)
|
||||
runs over TCP+TLS; the hub's channels endpoint runs over QUIC or
|
||||
TCP+TLS depending on the worker's transport. These coexist on one hub.
|
||||
|
||||
The channels protocol is transport-agnostic (ADR-071; ADR-065
|
||||
`Connection::from_stream`/`from_bidi`). The hub's accept and dial paths
|
||||
inherit that property — they take a `Connection`, not a `SocketAddr`
|
||||
welded to a dial. See "Transport" below.
|
||||
|
||||
### What the hub provides
|
||||
|
||||
1. **Peer lifecycle** — dial, accept, disconnect, reconnect with backoff.
|
||||
Identity resolution via `IdentityProvider`. One connection per peer.
|
||||
1. **Multi-transport endpoint** — accepts channels connections over
|
||||
QUIC (via the quinn `AlknetEndpoint`) and over TCP+TLS (via an
|
||||
accept loop that wraps each `TlsStream<TcpStream>` as a `Connection`
|
||||
with `from_bidi`, per ADR-065/010). Both feed the same
|
||||
`HandlerRegistry`. Also serves HTTP (`h2`/`http/1.1`) over TCP+TLS
|
||||
for registration and browser access. All three coexist.
|
||||
|
||||
2. **Aggregated operation env** — a shared `PeerCompositeEnv` across all
|
||||
calls (ADR-067). Operations discovered via `from_call` are registered in
|
||||
each peer's connection overlay and aggregated into the shared env.
|
||||
`invoke_peer` routes operation calls to the right peer.
|
||||
2. **Peer lifecycle** — accept, dial, disconnect, reconnect with
|
||||
backoff. Identity resolution via `IdentityProvider` (fingerprint or
|
||||
bearer token, depending on transport — see "Identity over
|
||||
transports"). One channels connection per peer.
|
||||
|
||||
3. **Service discovery** — `services/list-peers` returns each connected
|
||||
worker's operation list via `PeerCompositeEnv::peer_operations` (ADR-068).
|
||||
3. **Aggregated operation env** — a shared `PeerCompositeEnv` across
|
||||
all connected workers (ADR-067). Operations discovered via
|
||||
`from_call` are registered in each peer's connection overlay and
|
||||
aggregated into the shared env. `invoke_peer` routes operation calls
|
||||
to the right peer.
|
||||
|
||||
4. **Service discovery** — `services/list-peers` returns each
|
||||
connected worker's operation list via
|
||||
`PeerCompositeEnv::peer_operations` (ADR-068).
|
||||
|
||||
5. **Channel relay** — translates `channel/open` on channel 0 and
|
||||
byte-forwards data channels with `channel_id` rewrite (ADR-079).
|
||||
Lets a browser reach a spoke's channels through the hub without the
|
||||
hub parsing any protocol-specific framing.
|
||||
|
||||
6. **Worker registration** (in scope of the hub) — the HTTP endpoint
|
||||
that lets a freshly-provisioned worker enroll its key with a
|
||||
one-time registration token. The registration flow is what makes
|
||||
worker provisioning over TCP+TLS a hard requirement, not an
|
||||
option. See "Worker registration" below and OQ-58.
|
||||
|
||||
## Why
|
||||
|
||||
The alkapi project identified that the hub pattern requires wiring that
|
||||
alknet-call does not provide out of the box:
|
||||
The hub pattern requires wiring that `alknet-channels` and
|
||||
`alknet-call` do not provide out of the box:
|
||||
|
||||
- `Dispatcher::compose_root_env` builds a fresh `PeerCompositeEnv` per call
|
||||
with only the current connection — multi-worker aggregation is not wired
|
||||
(ADR-067).
|
||||
- `PeerCompositeEnv::peer_operations` is not overridden — `services/list-peers`
|
||||
returns empty operation lists for non-local peers (ADR-068).
|
||||
- `from_call` is a free function, not wired into `CallClient::connect` — the
|
||||
assembly layer must call it manually after every connect (ADR-069).
|
||||
- The hub accepts channels connections over **multiple transports**
|
||||
simultaneously. The channels crate is transport-agnostic (ADR-071,
|
||||
ADR-065); the hub is where the multi-transport accept loop lives.
|
||||
- The hub relays channels between legs (ADR-079) — terminating
|
||||
channel 0 on each leg, translating `channel/open`, byte-forwarding
|
||||
data channels. The channels crate is ALPN-blind and does not know it
|
||||
is being relayed; the relay is a hub-crate concern.
|
||||
- `Dispatcher::compose_root_env` builds a fresh `PeerCompositeEnv` per
|
||||
call with only the current connection — multi-worker aggregation is
|
||||
not wired (ADR-067).
|
||||
- `PeerCompositeEnv::peer_operations` is not overridden —
|
||||
`services/list-peers` returns empty operation lists for non-local
|
||||
peers (ADR-068).
|
||||
- `from_call` is a free function, not wired into `ChannelClient` —
|
||||
the assembly layer must call it manually after every connect
|
||||
(ADR-069).
|
||||
- There is no worker supervision loop — reconnection, backoff, and
|
||||
re-discovery are assembly-layer concerns.
|
||||
- There is no registration endpoint — a freshly-provisioned worker
|
||||
has no way to enroll its key with the hub before establishing a
|
||||
channels connection.
|
||||
|
||||
These are not design flaws; they are the correct separation of concerns.
|
||||
`alknet-call` provides the types and the routing logic; the hub wiring is a
|
||||
consumer concern. But it is a concern *every* hub consumer shares. Rather than
|
||||
each downstream project (alkapi, future hubs) building the same wiring
|
||||
independently, `alknet-hub` provides it once, as a reusable crate.
|
||||
These are not design flaws; they are the correct separation of
|
||||
concerns. The channels and call crates provide the types and the
|
||||
routing logic; the hub wiring is a consumer concern. But it is a
|
||||
concern *every* hub consumer shares. Rather than each downstream
|
||||
project (alkapi, future hubs) building the same wiring independently,
|
||||
`alknet-hub` provides it once, as a reusable crate.
|
||||
|
||||
## Architecture
|
||||
|
||||
### The hub is built on channels, not call-directly
|
||||
|
||||
The hub's substrate is `alknet/channels`, not `alknet/call` directly.
|
||||
Each peer (worker or browser) holds one channels connection to the
|
||||
hub. Channel 0 on each connection is `alknet/call` (ADR-072); the
|
||||
hub's `CallAdapter` runs on channel 0 for the hub's own operations,
|
||||
for `channel/open` translation (ADR-079), and for `from_call`
|
||||
discovery. Data channels carry the actual protocol work (TTY, SSH,
|
||||
tunnels) and are relayed byte-for-byte across legs.
|
||||
|
||||
This is the post-channels hub model. The pre-channels model (one QUIC
|
||||
connection per peer carrying `alknet/call` directly) is replaced: the
|
||||
connection is `alknet/channels`, and `alknet/call` rides channel 0.
|
||||
The hub's `CallClient`-direct dial path is replaced by
|
||||
`ChannelClient::from_connection` (ADR-080).
|
||||
|
||||
### Hub struct
|
||||
|
||||
The `Hub` is the central type. It owns the aggregated `PeerCompositeEnv`, the
|
||||
The `Hub` owns the aggregated `PeerCompositeEnv`, the
|
||||
`OperationRegistry`, and the `Dispatcher`:
|
||||
|
||||
```rust
|
||||
@@ -98,28 +171,98 @@ impl Hub {
|
||||
}
|
||||
```
|
||||
|
||||
The `Hub` exposes builder methods for optional hooks (`with_session_source`,
|
||||
`with_ownership_provider`, `with_timeout`) that delegate to the `Dispatcher`.
|
||||
The `Hub` exposes builder methods for optional hooks
|
||||
(`with_session_source`, `with_ownership_provider`,
|
||||
`with_timeout`) that delegate to the `Dispatcher`.
|
||||
|
||||
### Peer lifecycle
|
||||
### Transport
|
||||
|
||||
Every hub-worker connection carries the call protocol. The hub establishes the
|
||||
connection, runs `from_call` to discover the peer's operations, and attaches
|
||||
the peer to the aggregated env.
|
||||
The hub accepts and dials channels connections over any transport
|
||||
the channels protocol supports (ADR-071). In practice, a hub runs
|
||||
multiple accept loops simultaneously:
|
||||
|
||||
#### Inbound workers (workers dial the hub)
|
||||
| Endpoint | Transport | What it carries |
|
||||
|----------|-----------|-----------------|
|
||||
| Quinn `AlknetEndpoint` | QUIC (quinn/iroh) | Channels connections from workers with QUIC reachability |
|
||||
| TCP+TLS accept loop | TCP + TLS (`h2`/`http/1.1`/`alknet/channels`) | HTTP registration endpoint, browser access, channels-over-TCP from workers |
|
||||
| (Future) WebTransport | WebTransport | Browser bidirectional path (deferred per ADR-044; WebSocket is the v1 browser path) |
|
||||
|
||||
The hub's `CallAdapter` accepts inbound connections. The hub provides a
|
||||
`WorkerConnectedCallback` that fires inside the accept path, between
|
||||
connection-establish and dispatch-start. The callback runs `from_call`,
|
||||
registers the bundles, and attaches the peer to the aggregated env:
|
||||
All accept loops feed into the same `HandlerRegistry`. The hub's
|
||||
`HttpAdapter` serves `h2`/`http/1.1` over the TCP+TLS path for
|
||||
registration and browser access; the `ChannelsAdapter` (registered on
|
||||
the hub's `HandlerRegistry`) serves
|
||||
`alknet/channels` over the QUIC path (and over TCP+TLS when a worker
|
||||
dials channels-over-TCP). Both ALPNs are registered on the same
|
||||
registry; the TLS handshake on each connection negotiates the ALPN
|
||||
and dispatches to the right adapter.
|
||||
|
||||
The TCP+TLS accept loop constructs a `Connection` per accepted
|
||||
`TlsStream<TcpStream>` via `Connection::from_bidi` (ADR-065) and
|
||||
hands it to the same `HandlerRegistry::dispatch` the quinn endpoint
|
||||
uses. This is the accept-loop-outside-the-endpoint pattern from
|
||||
ADR-010's Amendment 1: the `AlknetEndpoint` struct stays QUIC-only
|
||||
(no `tcp` field), and the TCP+TLS loop is a sibling accept source
|
||||
that shares the registry. The hub is where that sibling loop lives.
|
||||
|
||||
#### Dial (outbound workers) — transport-agnostic
|
||||
|
||||
The hub dials outbound workers via `ChannelClient`, not `CallClient`.
|
||||
The dial path mirrors the `from_connection` / `connect_quic` split
|
||||
(ADR-080):
|
||||
|
||||
```rust
|
||||
/// Callback invoked by CallAdapter::handle when a worker connects inbound.
|
||||
/// Fires after identity resolution and before the dispatch loop starts.
|
||||
/// Carries both on_connected (runs from_call, registers discovered ops,
|
||||
/// attaches peer to aggregated env) and on_disconnected (detaches peer
|
||||
/// on run_loop exit).
|
||||
impl Hub {
|
||||
/// Take over a pre-established channels `Connection` as a worker
|
||||
/// connection. Transport-agnostic — the caller (or a transport
|
||||
/// helper) produces the `Connection`. This is the primary path;
|
||||
/// `connect_quic_worker` and future `connect_tcp_tls_worker` are
|
||||
/// conveniences over it.
|
||||
pub async fn dial_worker_connection(
|
||||
&self,
|
||||
connection: Connection,
|
||||
config: FromCallConfig,
|
||||
) -> Result<(PeerId, ChannelClient), HubError>;
|
||||
|
||||
/// QUIC convenience: dial a worker over QUIC, then
|
||||
/// `dial_worker_connection`. Two-way door — additive over
|
||||
/// `dial_worker_connection`.
|
||||
pub async fn connect_quic_worker(
|
||||
&self,
|
||||
addr: SocketAddr,
|
||||
credentials: CallCredentials,
|
||||
config: FromCallConfig,
|
||||
) -> Result<(PeerId, ChannelClient), HubError>;
|
||||
}
|
||||
```
|
||||
|
||||
`dial_worker_connection` is the transport-agnostic primary. After
|
||||
taking over the `Connection`, it runs `from_call` on channel 0 to
|
||||
discover the worker's operations, registers the discovered bundles in
|
||||
the connection's Layer 2 overlay, and attaches the peer to the
|
||||
aggregated env. `connect_quic_worker` is the "I just want QUIC"
|
||||
convenience — it calls `ChannelClient::connect_quic`, then
|
||||
`dial_worker_connection`. A future `connect_tcp_tls_worker` dials
|
||||
TCP+TLS and calls `dial_worker_connection` the same way. The
|
||||
one-way-door surface is `dial_worker_connection`; the dial helpers
|
||||
are two-way-door conveniences.
|
||||
|
||||
#### Accept (inbound workers and browsers) — transport-agnostic
|
||||
|
||||
Inbound connections arrive over whatever transport the accept loop
|
||||
yielded them from. The `ChannelsAdapter` (registered on
|
||||
`alknet/channels` in the `HandlerRegistry`) receives a `Connection` and runs the demux loop
|
||||
(ADR-075); the `CallAdapter` (running on channel 0) handles
|
||||
hub-level operations and `channel/open` translation (ADR-079).
|
||||
|
||||
The `WorkerConnectedCallback` fires inside `ChannelsAdapter::handle`
|
||||
between channel-0 establishment and dispatch start:
|
||||
|
||||
```rust
|
||||
/// Callback invoked by ChannelsAdapter::handle when a peer connects
|
||||
/// inbound. Fires after identity resolution and before the dispatch
|
||||
/// loop starts. Carries both on_connected (runs from_call, registers
|
||||
/// discovered ops, attaches peer to aggregated env) and on_disconnected
|
||||
/// (detaches peer on run_loop exit).
|
||||
pub struct WorkerConnectedCallback {
|
||||
hub: Arc<Hub>,
|
||||
config: FromCallConfig,
|
||||
@@ -155,142 +298,164 @@ impl WorkerConnectedCallback {
|
||||
}
|
||||
```
|
||||
|
||||
The callback is wired into `CallAdapter` via a new builder method:
|
||||
The callback is wired into `ChannelsAdapter` (the channels substrate)
|
||||
via a builder method. The `ChannelsAdapter::handle` flow becomes:
|
||||
|
||||
```rust
|
||||
impl CallAdapter {
|
||||
pub fn with_worker_connected_callback(
|
||||
mut self,
|
||||
callback: WorkerConnectedCallback,
|
||||
) -> Self {
|
||||
self.worker_connected_callback = Some(callback);
|
||||
self
|
||||
}
|
||||
}
|
||||
```
|
||||
1. Channel 0 (`alknet/call`) is preinstalled (ADR-072). The channel-0
|
||||
`Connection` is handed to the `CallAdapter`.
|
||||
2. Identity resolution — two paths depending on transport (see
|
||||
"Identity over transports"):
|
||||
- **Fingerprint path** (QUIC + raw key, or a transport with a
|
||||
client cert): the `AuthContext` carried into
|
||||
`ChannelsAdapter::handle` carries the peer's TLS fingerprint. The
|
||||
`CallAdapter` resolves it via `resolve_from_fingerprint`.
|
||||
- **Bearer-token path** (TCP+TLS with no client cert, WebTransport,
|
||||
WebSocket): the `CallAdapter` on channel 0 extracts `auth_token`
|
||||
from the first call frame (auth.md) and resolves it via
|
||||
`resolve_from_token`. The transport carries no identity of its
|
||||
own — the call protocol's first frame does.
|
||||
3. The `CallConnection` is constructed with the resolved identity.
|
||||
4. Invoke `on_connected` — runs `from_call`, registers bundles,
|
||||
attaches peer to aggregated env.
|
||||
5. Run the call-protocol dispatch loop on channel 0; the demux loop
|
||||
continues on the other channels.
|
||||
6. On disconnect, invoke `on_disconnected` — detaches peer from
|
||||
aggregated env.
|
||||
|
||||
The `CallAdapter::handle` flow becomes:
|
||||
|
||||
1. Resolve identity from the connection.
|
||||
2. Create `CallConnection`, set identity.
|
||||
3. Invoke `on_connected` callback (if set) — runs `from_call`, registers
|
||||
bundles, attaches peer to aggregated env.
|
||||
4. Run the dispatch loop (`run_loop`).
|
||||
5. On `run_loop` exit, invoke `on_disconnected` on the callback — detaches
|
||||
peer from aggregated env.
|
||||
|
||||
The assembly layer constructs the callback and passes it to `CallAdapter`:
|
||||
The assembly layer constructs the callback and passes it to
|
||||
`ChannelsAdapter`:
|
||||
|
||||
```rust
|
||||
let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::new());
|
||||
let adapter = CallAdapter::new(registry, identity_provider)
|
||||
.with_aggregated_env(hub.aggregated_env().clone())
|
||||
let channels_adapter = ChannelsAdapter::new(Arc::clone(®istry), /* ... */)
|
||||
.with_worker_connected_callback(callback);
|
||||
// Register channels_adapter on alknet/channels in the HandlerRegistry.
|
||||
// Both the quinn endpoint and the TCP+TLS accept loop dispatch
|
||||
// alknet/channels connections to it.
|
||||
```
|
||||
|
||||
> **Note on OQ-54**: The callback fires inside `handle()` because `handle()`
|
||||
> blocks until disconnect — there is no "after handle accepts" point for the
|
||||
> assembly layer to hook into. The callback is the committed design; OQ-54 is
|
||||
> resolved.
|
||||
### Identity over transports
|
||||
|
||||
#### Outbound workers (hub dials workers)
|
||||
A peer's identity is resolved differently depending on the transport
|
||||
the connection arrived over. This is not a hub invention — it is the
|
||||
existing identity model (ADR-030, ADR-034, auth.md) applied to the
|
||||
channels substrate.
|
||||
|
||||
The hub provides a `dial_worker` method that combines `CallClient::connect`,
|
||||
`from_call`, and `attach_peer` into a single operation. It also provides a
|
||||
`supervise_worker` method that wraps `dial_worker` in a reconnect loop with
|
||||
configurable backoff:
|
||||
| Transport | Identity source | Resolution path |
|
||||
|-----------|----------------|-----------------|
|
||||
| QUIC + RFC 7250 raw key | TLS fingerprint (automatic from handshake) | `resolve_from_fingerprint` — both sides present raw keys |
|
||||
| QUIC + X.509 (client cert) | TLS fingerprint (from client cert) | `resolve_from_fingerprint` — worker presents a client cert |
|
||||
| TCP+TLS (client cert) | TLS fingerprint (from client cert) | `resolve_from_fingerprint` — worker presents a client cert |
|
||||
| TCP+TLS (no client cert) | Bearer token (call-protocol `auth_token` in first frame on channel 0) | `resolve_from_token` |
|
||||
| WebTransport / WebSocket | Bearer token | `resolve_from_token` — browsers have no fingerprint (ADR-034 §4) |
|
||||
|
||||
The fingerprint path is the QUIC+raw-key optimization — identity is
|
||||
"free" because the TLS handshake carries it. The X.509-client-cert
|
||||
rows (QUIC and TCP+TLS) are the same path with a different cert
|
||||
format — the hub matches the client cert's fingerprint via
|
||||
`resolve_from_fingerprint` against the `SHA256:<hex>` entry in the
|
||||
peer's `PeerEntry` (the mixed-fingerprint case from ADR-034 §3). A
|
||||
worker may present an X.509 client cert over QUIC or TCP+TLS when the
|
||||
hub's deployment uses X.509 rather than raw keys; the identity model
|
||||
handles both identically.
|
||||
|
||||
The token path is the transport-agnostic fallback — it works over any
|
||||
transport that can carry a call-protocol first frame, which is all of
|
||||
them (channel 0 is `alknet/call`). `resolve_from_token` matches the
|
||||
token against `PeerEntry.auth_token_hash` and returns the same
|
||||
`PeerId` the fingerprint path would (ADR-030).
|
||||
|
||||
This is why channels-over-TCP is not a special case. The channels
|
||||
protocol runs `alknet/call` on channel 0 (ADR-072); the call
|
||||
protocol's `auth_token` path resolves identity from the first frame;
|
||||
the transport carries no identity burden of its own. A worker that
|
||||
registered its key over HTTP (TCP+TLS, no client cert) and then
|
||||
connects via channels-over-TCP authenticates with the bearer token it
|
||||
received at registration. A worker that connects via channels-over-QUIC
|
||||
authenticates with its TLS fingerprint. Both resolve to the same
|
||||
`PeerEntry` and the same `PeerId`.
|
||||
|
||||
### Worker registration
|
||||
|
||||
The registration flow is what makes TCP+TLS a hard requirement for
|
||||
the hub. A freshly-provisioned worker (container, vast.ai, runpod)
|
||||
does not yet have an established peer relationship with the hub — it
|
||||
has a one-time registration token supplied via the provisioning
|
||||
config. The flow:
|
||||
|
||||
1. The hub provisions a worker instance (docker, vast.ai, runpod —
|
||||
platform-specific) and supplies a registration token via
|
||||
`onStartCMD` (or equivalent).
|
||||
2. The instance downloads the worker binary.
|
||||
3. The instance generates an Ed25519 key pair (its future identity).
|
||||
4. The instance POSTs to the hub's HTTP registration endpoint over
|
||||
TCP+TLS, sending its public key and the registration token.
|
||||
5. The hub validates the token, creates a `PeerEntry` for the worker
|
||||
recording *both* the fingerprint (from the public key) and an
|
||||
`auth_token_hash` (from a session token the hub issues), and returns
|
||||
the session credential. The `PeerEntry` is the mixed-fingerprint
|
||||
shape from ADR-034 §3 — fingerprint for the QUIC path,
|
||||
`auth_token_hash` for the TCP+TLS path.
|
||||
6. The instance connects to the hub via channels — over QUIC
|
||||
(fingerprint identity) or over TCP+TLS (bearer-token identity) —
|
||||
and both resolve to the same `PeerEntry` and the same `PeerId`.
|
||||
The ongoing session begins.
|
||||
|
||||
Step 4 is HTTP over TCP+TLS. Step 6 is channels over QUIC or TCP+TLS.
|
||||
Both happen; they are not alternatives. The registration endpoint is
|
||||
an HTTP route on the `HttpAdapter` (registered on the hub's
|
||||
`HandlerRegistry`, served on `h2`/`http/1.1`
|
||||
over TCP+TLS), not a call-protocol operation — the worker has no
|
||||
`CallConnection` yet at step 4.
|
||||
|
||||
The registration endpoint and the enrollment-token model are a
|
||||
one-way-door API (the endpoint shape, the token semantics). That
|
||||
decision is tracked as OQ-58 — it is decision-ready in shape (HTTP
|
||||
POST, token in, `PeerEntry` out, session credential returned) but
|
||||
the exact token model (one-time vs. refresh, single-use vs.
|
||||
multi-use, rotation) and the endpoint path need a dedicated ADR
|
||||
before the hub crate stabilizes.
|
||||
|
||||
### Supervision
|
||||
|
||||
The hub provides a `supervise_worker` method that wraps
|
||||
`dial_worker_connection` (or a transport-specific helper) in a
|
||||
reconnect loop with configurable backoff:
|
||||
|
||||
```rust
|
||||
impl Hub {
|
||||
/// Dial a worker, establish the call-protocol connection, discover its
|
||||
/// operations, and attach it to the aggregated env. Returns the
|
||||
/// worker's PeerId and the live CallConnection.
|
||||
pub async fn dial_worker(
|
||||
&self,
|
||||
addr: SocketAddr,
|
||||
credentials: CallCredentials,
|
||||
config: FromCallConfig,
|
||||
) -> Result<(PeerId, CallConnection), HubError> {
|
||||
let client = CallClient::new(
|
||||
Arc::clone(&self.registry),
|
||||
Arc::clone(&self.identity_provider),
|
||||
);
|
||||
let connection = client.connect(addr, credentials).await?;
|
||||
|
||||
let peer_id = connection.identity()
|
||||
.map(|id| id.id.clone())
|
||||
.ok_or(HubError::NoPeerIdentity)?;
|
||||
|
||||
let bundles = from_call(&connection, config).await?;
|
||||
connection.register_imported_all(bundles);
|
||||
|
||||
self.aggregated_env
|
||||
.write()
|
||||
.expect("aggregated env lock poisoned")
|
||||
.attach_peer(peer_id.clone(), connection.overlay_env());
|
||||
|
||||
Ok((peer_id, connection))
|
||||
}
|
||||
|
||||
/// Detach a peer from the aggregated env. Called on disconnect
|
||||
/// (both inbound and outbound).
|
||||
fn on_worker_disconnected(&self, peer_id: &PeerId) {
|
||||
self.aggregated_env
|
||||
.write()
|
||||
.expect("aggregated env lock poisoned")
|
||||
.detach_peer(peer_id);
|
||||
}
|
||||
|
||||
/// Supervise an outbound worker: dial, discover, attach. On disconnect,
|
||||
/// detach and retry with backoff. Runs until the Hub is dropped (the
|
||||
/// returned JoinHandle can be aborted).
|
||||
pub fn supervise_worker(
|
||||
/// Supervise an outbound worker: dial, discover, attach. On
|
||||
/// disconnect, detach and retry with backoff. Runs until the Hub
|
||||
/// is dropped (the returned JoinHandle can be aborted).
|
||||
///
|
||||
/// `dial` is a closure that produces a channels `Connection` —
|
||||
/// the hub does not bake a transport into the supervision loop.
|
||||
/// The caller provides e.g. `|| async { Ok(Connection::from_quinn(quinn_endpoint.connect(addr, "alknet/channels")?.await?)) }`
|
||||
/// for QUIC, or `|| async { Ok(Connection::from_bidi(tls_connector.connect(host, TcpStream::connect(addr).await?).await?, b"alknet/channels".to_vec(), Some(addr))) }`
|
||||
/// for TCP+TLS. The supervision loop is transport-agnostic.
|
||||
pub fn supervise_worker<F, Fut>(
|
||||
self: &Arc<Self>,
|
||||
addr: SocketAddr,
|
||||
credentials: CallCredentials,
|
||||
dial: F,
|
||||
config: FromCallConfig,
|
||||
backoff: BackoffConfig,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
let hub = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let mut retries = 0;
|
||||
loop {
|
||||
match hub.dial_worker(addr, credentials.clone(), config.clone()).await {
|
||||
Ok((peer_id, connection)) => {
|
||||
retries = 0;
|
||||
// Wait for the connection to drop (run_loop exits).
|
||||
// Committed interim (OQ-52): poll the underlying
|
||||
// Connection's accept_bi() until it returns
|
||||
// ConnectionClosed. A CallConnection::closed()
|
||||
// method is the target resolution.
|
||||
loop {
|
||||
match connection.connection() {
|
||||
Some(conn) => {
|
||||
match conn.accept_bi().await {
|
||||
Err(StreamError::ConnectionClosed)
|
||||
| Err(StreamError::StreamClosed)
|
||||
| Err(StreamError::Timeout) => break,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
hub.on_worker_disconnected(&peer_id);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(?e, retries, "worker dial failed; retrying");
|
||||
}
|
||||
}
|
||||
let delay = backoff.delay_for(retries);
|
||||
tokio::time::sleep(delay).await;
|
||||
retries += 1;
|
||||
}
|
||||
})
|
||||
}
|
||||
) -> tokio::task::JoinHandle<()>
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = Result<Connection, HubError>> + Send + 'static;
|
||||
}
|
||||
```
|
||||
|
||||
The supervision loop takes a `dial` closure rather than a `SocketAddr`
|
||||
+ `CallCredentials` pair. This keeps the loop transport-agnostic —
|
||||
the caller decides the transport by what the closure does. The
|
||||
backoff and re-discovery logic is the same regardless of transport.
|
||||
|
||||
Disconnect is detected via the OQ-52 interim — the loop polls
|
||||
`connection().accept_bi()` until `ConnectionClosed` — pending a
|
||||
`CallConnection::closed()` method (OQ-52 target resolution). On
|
||||
disconnect, the peer is detached from the aggregated env and the
|
||||
loop retries with backoff.
|
||||
|
||||
### Backoff configuration
|
||||
|
||||
```rust
|
||||
@@ -320,12 +485,27 @@ impl Default for BackoffConfig {
|
||||
}
|
||||
```
|
||||
|
||||
### Channel relay (ADR-079)
|
||||
|
||||
When a browser (or any non-peer client) connects to the hub and opens
|
||||
a channel to a spoke, the hub translates `channel/open` on channel 0
|
||||
(terminate on both legs, re-issue with `forwarded_for` — ADR-032) and
|
||||
byte-forwards data channels with `channel_id` rewrite. `channel/control`
|
||||
operations on channel 0 carry `channel_id` in their JSON payload; the
|
||||
hub's `CallAdapter` translates these too, rewriting `channel_id` to
|
||||
the other leg's id (ADR-079). The hub does not run protocol-specific
|
||||
handlers (`alknet/tty`, `alknet/ssh`, `alknet/tunnel`) — it runs
|
||||
`alknet/channels` (the relay) and `alknet/call` (for its own ops +
|
||||
translation). The full relay contract is in ADR-079; the relay
|
||||
implementation lives in `alknet-hub`.
|
||||
|
||||
### Service discovery
|
||||
|
||||
The hub registers the built-in service discovery operations (`services/list`,
|
||||
`services/schema`, `services/list-peers`) automatically. The
|
||||
`services/list-peers` handler returns each connected worker's operation list
|
||||
via `PeerCompositeEnv::peer_operations` (ADR-068).
|
||||
The hub registers the built-in service discovery operations
|
||||
(`services/list`, `services/schema`, `services/list-peers`)
|
||||
automatically. The `services/list-peers` handler returns each
|
||||
connected worker's operation list via
|
||||
`PeerCompositeEnv::peer_operations` (ADR-068).
|
||||
|
||||
### HubError
|
||||
|
||||
@@ -339,41 +519,63 @@ pub enum HubError {
|
||||
Discovery(#[from] AdapterError),
|
||||
#[error("call client error: {0}")]
|
||||
Client(#[from] ClientError),
|
||||
#[error("channel error (client or adapter path): {0}")]
|
||||
Channel(#[from] ChannelError),
|
||||
#[error("registration failed: {0}")]
|
||||
Registration(#[from] RegistrationError),
|
||||
}
|
||||
```
|
||||
|
||||
`RegistrationError` lives in `alknet-hub` (the registration endpoint is
|
||||
a hub-crate surface). Its variants are defined alongside the OQ-58
|
||||
resolution — the likely shape is `InvalidToken`, `ExpiredToken`,
|
||||
`AlreadyEnrolled`, `Store(StoreError)`, but the exact set is not
|
||||
fixed until the enrollment-token model is decided.
|
||||
|
||||
## What the hub does NOT do
|
||||
|
||||
- **Worker authentication policy.** The hub resolves the worker's identity
|
||||
via `IdentityProvider` (the existing mechanism). Whether a given identity is
|
||||
*allowed* to connect as a worker is an `AccessControl` decision on the
|
||||
assembly layer's curated ops — the hub does not add a separate worker-auth
|
||||
layer.
|
||||
- **Worker-specific routing policy.** `PeerRef::Any` uses insertion-order
|
||||
first-match (ADR-029 §2). A richer `RoutingPolicy` (round-robin,
|
||||
least-loaded) is a future extension behind the same `PeerRef` enum.
|
||||
- **Multi-hop federation.** The hub is one-hop: workers connect to the hub,
|
||||
the hub composes their ops. Worker A does not transitively see worker B's
|
||||
ops through the hub unless the hub explicitly re-exports them (ADR-029
|
||||
Assumption 5).
|
||||
- **Worker provisioning.** The hub does not spawn workers, configure them, or
|
||||
manage their lifecycle beyond connection supervision. Worker provisioning
|
||||
is an assembly-layer concern.
|
||||
- **Worker authentication policy.** The hub resolves the worker's
|
||||
identity via `IdentityProvider` (the existing mechanism). Whether a
|
||||
given identity is *allowed* to connect as a worker is an
|
||||
`AccessControl` decision on the assembly layer's curated ops — the
|
||||
hub does not add a separate worker-auth layer.
|
||||
- **Worker-specific routing policy.** `PeerRef::Any` uses
|
||||
insertion-order first-match (ADR-029 §2). A richer
|
||||
`RoutingPolicy` (round-robin, least-loaded) is a future extension
|
||||
behind the same `PeerRef` enum.
|
||||
- **Multi-hop federation.** The hub is one-hop: workers connect to
|
||||
the hub, the hub composes their ops. Worker A does not transitively
|
||||
see worker B's ops through the hub unless the hub explicitly
|
||||
re-exports them (ADR-029 Assumption 5).
|
||||
- **Worker provisioning.** The hub does not spawn workers, configure
|
||||
them, or manage their lifecycle beyond connection supervision. The
|
||||
registration endpoint enrolls a key; it does not provision the
|
||||
instance. Worker provisioning (docker, vast.ai, runpod) is an
|
||||
assembly-layer concern that calls the hub's registration endpoint
|
||||
after provisioning.
|
||||
|
||||
## Crate dependencies
|
||||
|
||||
```
|
||||
alknet-hub
|
||||
├── alknet-call (CallClient, CallAdapter, Dispatcher, PeerCompositeEnv,
|
||||
├── alknet-channels-call (ChannelClient, ChannelsAdapter, ChannelManager,
|
||||
│ ChannelBidiStreamSource)
|
||||
├── alknet-call (CallAdapter, Dispatcher, PeerCompositeEnv,
|
||||
│ from_call, FromCallConfig, AdapterError, ClientError)
|
||||
├── alknet-core (IdentityProvider, Connection, OperationRegistry)
|
||||
├── alknet-http (HttpAdapter — for the registration endpoint and browser access)
|
||||
├── alknet-core (IdentityProvider, Connection, OperationRegistry,
|
||||
│ HandlerRegistry, AuthContext)
|
||||
├── tokio (spawn, time::sleep)
|
||||
└── tracing (logging)
|
||||
```
|
||||
|
||||
`alknet-hub` depends on `alknet-call`, which depends on `alknet-core`. No new
|
||||
dependency edges. The crate is a consumer of the call protocol types, not a
|
||||
new protocol handler.
|
||||
`alknet-hub` depends on `alknet-channels-call`, `alknet-call`, and
|
||||
`alknet-http`, all of which depend on `alknet-core`. The hub is a
|
||||
consumer of the channels substrate and the call protocol, not a new
|
||||
protocol handler. The `alknet-http` dependency is for the
|
||||
registration endpoint and browser HTTP access — the hub wires
|
||||
`HttpAdapter` into the same `HandlerRegistry` as
|
||||
`ChannelsAdapter`.
|
||||
|
||||
## Assembly layer integration
|
||||
|
||||
@@ -394,68 +596,143 @@ let hub = Arc::new(Hub::new(
|
||||
Arc::clone(&identity_provider),
|
||||
).with_ownership_provider(ownership_provider));
|
||||
|
||||
// 3. Start the inbound call-protocol listener (workers dial the hub)
|
||||
// 3. Register the ChannelsAdapter on alknet/channels. Both the quinn
|
||||
// endpoint and the TCP+TLS accept loop dispatch alknet/channels
|
||||
// connections to this adapter.
|
||||
let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::new());
|
||||
let adapter = CallAdapter::new(Arc::clone(®istry), Arc::clone(&identity_provider))
|
||||
.with_aggregated_env(hub.aggregated_env().clone())
|
||||
let channels_adapter = ChannelsAdapter::new(/* ... */)
|
||||
.with_worker_connected_callback(callback);
|
||||
// ... register adapter on the QUIC endpoint
|
||||
registry.register(b"alknet/channels", Arc::new(channels_adapter));
|
||||
|
||||
// 4. Dial outbound workers (hub dials workers)
|
||||
// 4. Register the HttpAdapter on h2/http1.1. The TCP+TLS accept loop
|
||||
// dispatches h2/http1.1 connections to this adapter (registration
|
||||
// endpoint, browser access, stealth decoy).
|
||||
let http_adapter = HttpAdapter::new(/* ... */);
|
||||
registry.register(b"h2", Arc::new(http_adapter.clone()));
|
||||
registry.register(b"http/1.1", Arc::new(http_adapter));
|
||||
|
||||
// 5. Start the QUIC accept loop (workers with QUIC reachability)
|
||||
let quinn_endpoint = AlknetEndpoint::new(/* ... */, Arc::clone(®istry));
|
||||
quinn_endpoint.run().await;
|
||||
|
||||
// 6. Start the TCP+TLS accept loop (registration, browser access,
|
||||
// channels-over-TCP from workers)
|
||||
let tcp_listener = TcpListener::bind(registration_addr).await?;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (stream, _) = tcp_listener.accept().await?;
|
||||
let tls_stream = tls_acceptor.accept(stream).await?;
|
||||
let alpn = tls_stream.alpn()?;
|
||||
let conn = Connection::from_bidi(tls_stream, alpn, /* remote_addr */);
|
||||
registry.dispatch(conn).await; // routes by ALPN to HttpAdapter or ChannelsAdapter
|
||||
}
|
||||
});
|
||||
|
||||
// 7. Dial outbound workers (hub dials workers). The closure produces a
|
||||
// channels Connection — the hub's supervise_worker calls
|
||||
// dial_worker_connection internally. Both transports are shown; a
|
||||
// real deployment picks one per worker.
|
||||
|
||||
// QUIC dial:
|
||||
hub.supervise_worker(
|
||||
dev1_addr,
|
||||
dev1_credentials,
|
||||
|| async move {
|
||||
// Dial QUIC, wrap the quinn connection as a channels Connection.
|
||||
let quinn_conn = quinn_endpoint
|
||||
.connect(worker_addr, "alknet/channels")?
|
||||
.await?;
|
||||
Ok(Connection::from_quinn(quinn_conn))
|
||||
},
|
||||
FromCallConfig::new(),
|
||||
BackoffConfig::default(),
|
||||
);
|
||||
|
||||
// 5. Start the HTTP listener (clients dial the hub)
|
||||
// ... HttpAdapter with the same registry and identity_provider
|
||||
// TCP+TLS dial (e.g., for a worker that can't reach the hub over QUIC):
|
||||
hub.supervise_worker(
|
||||
|| async move {
|
||||
let tcp = TcpStream::connect(worker_addr).await?;
|
||||
let tls = tls_connector.connect("worker.example.com", tcp).await?;
|
||||
// from_bidi wraps the TlsStream as a channels Connection
|
||||
// (ADR-065). The ALPN must be alknet/channels.
|
||||
Ok(Connection::from_bidi(tls, b"alknet/channels".to_vec(), Some(worker_addr)))
|
||||
},
|
||||
FromCallConfig::new(),
|
||||
BackoffConfig::default(),
|
||||
);
|
||||
```
|
||||
|
||||
The hub's `aggregated_env()` accessor returns the shared `Arc<RwLock<PeerCompositeEnv>>`
|
||||
so the assembly layer can wire it into `CallAdapter::with_aggregated_env`.
|
||||
The hub's `aggregated_env()` accessor returns the shared
|
||||
`Arc<RwLock<PeerCompositeEnv>>` so the assembly layer can wire it
|
||||
into `CallAdapter::with_aggregated_env`.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | ADR | Summary |
|
||||
|----------|-----|---------|
|
||||
| Hub relay — translate, not transparently forward | [ADR-079](../../decisions/079-hub-relay-translate-not-forward.md) | Translate `channel/open` on channel 0 with `forwarded_for`; byte-forward data channels with `channel_id` rewrite |
|
||||
| Aggregated peer-env wiring | [ADR-067](../../decisions/067-aggregated-peer-env-wiring.md) | `Dispatcher::with_aggregated_env` hook; `compose_root_env` reads shared env |
|
||||
| PeerCompositeEnv::peer_operations | [ADR-068](../../decisions/068-peer-composite-env-peer-operations.md) | `list_operation_names` trait method; `PeerCompositeEnv` override |
|
||||
| from_call is manual | [ADR-069](../../decisions/069-from-call-manual-free-function.md) | `from_call` is a free function; the hub calls it after connect |
|
||||
| Peer-graph routing model | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays, `PeerRef` routing, `AccessControl`-based peer auth |
|
||||
| PeerEntry and Identity.id | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` = `Identity.id` = `PeerEntry.peer_id` (stable) |
|
||||
| Three peer roles | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Hub = role-3 `PeerEntry` (mixed fingerprints); browsers not peers; bearer-token identity over TCP/WebTransport |
|
||||
| ChannelClient — transport-agnostic | [ADR-080](../../decisions/080-channelclient.md) | `from_connection` primary, `connect_quic` convenience; the dial path the hub uses |
|
||||
| Channels transport-agnostic | [ADR-071](../../decisions/071-channels-wire-format.md) | Substrate modes; `Connection::from_stream`/`from_bidi` (ADR-065) — the substrate the hub relays |
|
||||
| TCP+TLS dispatch via from_stream | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) Am. 1 | `AlknetEndpoint` stays QUIC-only; TCP+TLS accept loop shares the registry |
|
||||
| Channel 0 pre-negotiated | [ADR-072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 = `alknet/call`; the `CallAdapter` runs here |
|
||||
| Channel lifecycle operations | [ADR-073](../../decisions/073-channel-lifecycle-operations.md) | `channel/open`/`close`/`control`/`resources/subscribe` — what the hub translates |
|
||||
|
||||
## Open Questions
|
||||
|
||||
See [open-questions.md](../../open-questions.md) for full details.
|
||||
|
||||
- **OQ-52** (open): `CallConnection::wait_for_close()` — the supervision loop
|
||||
needs a way to await connection close. The committed interim is polling
|
||||
`connection().accept_bi()` until `ConnectionClosed`. A `closed()` method on
|
||||
`CallConnection` is the target resolution.
|
||||
- **OQ-53** (open): `BackoffConfig` defaults — the committed policy is 1s
|
||||
initial, 60s max, 2x multiplier. OQ-53 tracks whether operational
|
||||
experience warrants a change before the first release.
|
||||
- **OQ-54** (resolved): Inbound worker hook placement — the callback fires
|
||||
inside `CallAdapter::handle()` between identity resolution and dispatch
|
||||
start. `handle()` blocks until disconnect, so there is no "after handle
|
||||
accepts" point for the assembly layer to hook into. The
|
||||
`WorkerConnectedCallback` is the committed design.
|
||||
- **OQ-58** (open): Worker registration flow — the enrollment-token
|
||||
model, the HTTP registration endpoint shape, and the
|
||||
`register_worker` API. Decision-ready in shape (HTTP POST, token in,
|
||||
`PeerEntry` created, session credential returned); the exact token
|
||||
model (one-time vs. refresh, rotation) and endpoint path need a
|
||||
dedicated ADR before the hub crate stabilizes.
|
||||
- **OQ-52** (open): `CallConnection::wait_for_close()` — the
|
||||
supervision loop needs a way to await connection close. The
|
||||
committed interim is polling `connection().accept_bi()` until
|
||||
`ConnectionClosed`. A `closed()` method on `CallConnection` is the
|
||||
target resolution.
|
||||
- **OQ-53** (open): `BackoffConfig` defaults — the committed policy
|
||||
is 1s initial, 60s max, 2x multiplier. OQ-53 tracks whether
|
||||
operational experience warrants a change before the first release.
|
||||
- **OQ-54** (resolved): Inbound worker hook placement — the callback
|
||||
fires inside `ChannelsAdapter::handle()` between identity resolution
|
||||
and dispatch start. `handle()` blocks until disconnect, so there is
|
||||
no "after handle accepts" point for the assembly layer to hook into.
|
||||
The `WorkerConnectedCallback` is the committed design.
|
||||
|
||||
## References
|
||||
|
||||
- [channel-client.md](../channels/channel-client.md) — `ChannelClient`
|
||||
(`from_connection` / `connect_quic` — the dial path)
|
||||
- [channels-adapter.md](../channels/channels-adapter.md) —
|
||||
`ChannelsAdapter`, `ChannelManager`, the accept path
|
||||
- [channel-operations.md](../channels/channel-operations.md) —
|
||||
`channel/open`/`close`/`control`, the hub relay contract
|
||||
- [client-and-adapters.md](../call/client-and-adapters.md) — `CallClient`,
|
||||
`from_call`, `OperationAdapter`
|
||||
- [call-protocol.md](../call/call-protocol.md) — `CallAdapter`, `Dispatcher`,
|
||||
`CallConnection`
|
||||
- [operation-registry.md](../call/operation-registry.md) — `OperationRegistry`,
|
||||
`OperationRegistryBuilder`, `OperationEnv`
|
||||
- [call-protocol.md](../call/call-protocol.md) — `CallAdapter`,
|
||||
`Dispatcher`, `CallConnection`
|
||||
- [operation-registry.md](../call/operation-registry.md) —
|
||||
`OperationRegistry`, `OperationRegistryBuilder`, `OperationEnv`
|
||||
- [http-server.md](../http/http-server.md) — `HttpAdapter` (the
|
||||
registration endpoint and browser HTTP access)
|
||||
- [auth.md](../core/auth.md) — `resolve_from_token`,
|
||||
`resolve_from_fingerprint` (the identity paths over transports)
|
||||
- ADR-029: Peer-Graph Routing Model
|
||||
- ADR-034: Three Peer Roles (hub = role-3, bearer-token identity)
|
||||
- ADR-065: `Connection::from_stream`/`from_bidi` (TCP+TLS path)
|
||||
- ADR-067: Aggregated Peer-Environment Wiring
|
||||
- ADR-068: PeerCompositeEnv::peer_operations Override
|
||||
- ADR-069: from_call Is a Manual Free Function
|
||||
- alkapi [hub.md](/workspace/@alkdev/alkapi/docs/architecture/hub.md) — the
|
||||
first hub consumer, the concrete use case that informed this crate
|
||||
- ADR-079: Hub Relay — Translate, Not Transparently Forward
|
||||
- ADR-080: ChannelClient (transport-agnostic `from_connection`)
|
||||
- alkapi [hub.md](/workspace/@alkdev/alkapi/docs/architecture/hub.md) —
|
||||
the first hub consumer, the concrete use case that informed this
|
||||
crate
|
||||
- alkapi [ADR-011](/workspace/@alkdev/alkapi/docs/architecture/decisions/011-aggregated-peer-env.md) —
|
||||
the downstream aggregation decision
|
||||
the downstream aggregation decision
|
||||
@@ -218,7 +218,11 @@ the dependency direction (hub/worker → channels, not channels → hub/worker).
|
||||
- ADR-074: ChannelBidiStreamSource (in `channels-core`)
|
||||
- ADR-075: ChannelsAdapter and ChannelManager (split: core demux in
|
||||
`channels-core`, call coupling in `channels-call`)
|
||||
- ADR-079: hub relay (in `channels-hub` or `alknet-hub`)
|
||||
- ADR-080: ChannelClient (in `channels-worker`)
|
||||
- ADR-079: hub relay (in `alknet-hub` — the hub crate consumes
|
||||
`channels-call`; there is no `channels-hub` sub-crate, per this ADR's
|
||||
Decision §"No hub/worker sub-crates")
|
||||
- ADR-080: ChannelClient (in `channels-call`; there is no
|
||||
`channels-worker` sub-crate — a worker is any crate that uses
|
||||
`ChannelClient` to dial)
|
||||
- `docs/architecture/crates/hub/README.md` — the existing hub crate (the
|
||||
relay's consumer)
|
||||
@@ -166,6 +166,7 @@ Door type is separate from whether a decision is made. A two-way door is a decis
|
||||
| [OQ-52](questions/052-callconnection-wait-for-close.md) | CallConnection::wait_for_close() for supervision loop | open | two | med |
|
||||
| [OQ-53](questions/053-backoff-config-defaults.md) | BackoffConfig default policy | open | two | low |
|
||||
| [OQ-54](questions/054-inbound-worker-hook-placement.md) | Inbound worker on_worker_connected hook placement | resolved | two | low |
|
||||
| [OQ-58](questions/058-worker-registration-flow.md) | Worker Registration Flow | open | one | high |
|
||||
|
||||
### alknet-channels
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# OQ-58: Worker Registration Flow
|
||||
|
||||
- **Origin**: `docs/architecture/crates/hub/README.md` §"Worker
|
||||
registration" — the provisioning → enrollment → connection sequence
|
||||
that makes TCP+TLS a hard requirement for the hub.
|
||||
- **Status**: open
|
||||
- **Door type**: one-way (the registration endpoint shape and the
|
||||
enrollment-token model are an API surface; changing them after
|
||||
workers are provisioned against them is a breaking change for every
|
||||
deployment)
|
||||
- **Priority**: high
|
||||
- **Blocked on**: nothing structural — the identity machinery
|
||||
(`resolve_from_token`, `PeerEntry.auth_token_hash`, ADR-030/034)
|
||||
and the HTTP substrate (`HttpAdapter` on `h2`/`http/1.1` over
|
||||
TCP+TLS, ADR-010 Am. 1 / ADR-065) both exist. What remains is
|
||||
deciding the token model and the endpoint shape, not building new
|
||||
primitives.
|
||||
- **Resolution**: Not yet decided. The flow is decision-ready in
|
||||
shape (see "Decision-ready shape" below). The open sub-questions are:
|
||||
1. **Enrollment-token model.** Is the registration token one-time
|
||||
single-use (burned on first POST), one-time-per-worker (a worker
|
||||
can retry the POST until it succeeds, then the token is
|
||||
invalidated), or refresh-capable (the token can enroll multiple
|
||||
workers, e.g. a pool token)? One-time-per-worker is the likely
|
||||
default (matches the provisioning flow: one token per instance);
|
||||
pool tokens are a feature extension.
|
||||
2. **Session credential returned.** After registration, does the
|
||||
hub return a bearer token for the ongoing channels connection
|
||||
(the worker authenticates over TCP+TLS via `auth_token`), or
|
||||
does it record the worker's fingerprint and expect fingerprint
|
||||
auth on the channels connection (the worker authenticates over
|
||||
QUIC via its raw key)? Both are valid; the choice depends on
|
||||
whether the worker's transport is known at registration time. A
|
||||
hub that accepts both should probably support both return shapes
|
||||
(return a token *and* record the fingerprint).
|
||||
3. **Endpoint path.** `POST /register`? `POST /v1/workers/register`?
|
||||
A versioned path is safer for a one-way door.
|
||||
4. **Token source.** Does the hub generate the enrollment token, or
|
||||
does the assembly layer (the provisioning system) generate it
|
||||
and the hub just validates it? The latter keeps the hub out of
|
||||
the token-generation business but requires a shared secret or
|
||||
signature scheme.
|
||||
- **Decision-ready shape**: the registration endpoint is an HTTP POST
|
||||
on the hub's `HttpAdapter` (served on `h2`/`http/1.1` over TCP+TLS).
|
||||
The request carries the worker's public key and the enrollment
|
||||
token. The hub validates the token, creates a `PeerEntry` for the
|
||||
worker (fingerprint from the key, `auth_token_hash` from a
|
||||
session token the hub issues), and returns the session credential.
|
||||
The worker then connects via channels (QUIC or TCP+TLS) and
|
||||
authenticates with the fingerprint or the bearer token. The
|
||||
`PeerEntry` created at registration is what `resolve_from_token`
|
||||
/ `resolve_from_fingerprint` matches at connection time.
|
||||
- **What does NOT block on this**: the hub's multi-transport accept
|
||||
loop, the channels-over-TCP path, the identity-over-TCP path
|
||||
(bearer token via `resolve_from_token`), and the supervision loop.
|
||||
All of these are decided and use existing machinery. OQ-58 is
|
||||
about the registration endpoint specifically — the one new surface
|
||||
the hub introduces.
|
||||
- **Cross-references**: ADR-030 (PeerEntry, auth_token_hash),
|
||||
ADR-034 (bearer-token identity over non-fingerprint transports),
|
||||
ADR-010 Am. 1 (TCP+TLS dispatch via from_stream), ADR-065
|
||||
(Connection::from_bidi), ADR-080 (ChannelClient::from_connection),
|
||||
OQ-52 (CallConnection::wait_for_close — the supervision loop the
|
||||
registration flow feeds into).
|
||||
Reference in new issue
Block a user