ADR-085 records the actual workspace scope: the mono-repo is the core networking toolkit (substrate: core, tls, call, channels; deployment shapes: hub, worker; foundational handlers: tty, http, ssh, tunnel, socks5, fs, sftp; vault). Consumer repos (docker, agent) are separate repos depending on the published core crates. The overview's crate graph had been describing the wrong scope since ADR-003 — a flat ~12-crate workspace including DNS/messaging/NAPI while omitting channels, hub, worker, and tls. This stale scope was a causal factor in the 'assembly layer' hedging pattern: when the overview implies everything lives in one repo but the architecture needs a hub/worker composition layer not in the graph, the gap gets filled with 'assembly layer' as an escape hatch. The overview is rewritten to match the real boundary. TLS spec review fixes (from architecture review): - C3: hub/worker/hub-worker terminology pointers (tls README + endpoint.md) - W1: server-only statement + OQ-64 (client-side TLS helper, deferred) - W2: ACME task lifecycle semantics (returns immediately, no first-cert await) - W3: remove stale EndpointError::TlsConfig variant - W4: update stale ALPN section for two-config hub - W5: add alknet-tls to hub dep graph (assembly-layer dep) - W6: trim inline rationale -> point to ADR-084 - W7: ADR-084 status dependency note New open questions: - OQ-62: ALPN list sharing for two-config hub (open, high) - OQ-63: TlsError shape (open, high) - OQ-64: client-side TLS helper (deferred, blocked on OQ-55)
35 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-07-14 |
alknet-hub
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 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
-
Multi-transport endpoint — accepts channels connections over QUIC and over TCP+TLS (both owned transports on
AlknetEndpointviawith_quinn/with_tcp_tls, per ADR-083). Both feed the same dispatch path. Also serves HTTP (h2/http/1.1) over TCP+TLS for registration and browser access. All three coexist; the endpoint owns all accept loops andshutdown()stops them all. -
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. -
Aggregated operation env — a shared
PeerCompositeEnvacross all connected workers (ADR-067). Operations discovered viafrom_callare registered in each peer's connection overlay and aggregated into the shared env.invoke_peerroutes operation calls to the right peer. -
Service discovery —
services/list-peersreturns each connected worker's operation list viaPeerCompositeEnv::peer_operations(ADR-068). -
Channel relay — translates
channel/openon channel 0 and byte-forwards data channels withchannel_idrewrite (ADR-079). Lets a browser reach a spoke's channels through the hub without the hub parsing any protocol-specific framing. -
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 hub pattern requires wiring that alknet-channels and
alknet-call do not provide out of the box:
- The hub accepts channels connections over multiple transports
simultaneously. The channels crate is transport-agnostic (ADR-071,
ADR-065); the hub composes the multi-transport endpoint
(
AlknetEndpointwithwith_quinn/with_tcp_tls, ADR-083). The accept loops themselves live inalknet-core; the hub provides the handlers and wiring. - 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_envbuilds a freshPeerCompositeEnvper call with only the current connection — multi-worker aggregation is not wired (ADR-067).PeerCompositeEnv::peer_operationsis not overridden —services/list-peersreturns empty operation lists for non-local peers (ADR-068).from_callis a free function, not wired intoChannelClient— 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. 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 owns the aggregated PeerCompositeEnv, the
OperationRegistry, and the Dispatcher:
pub struct Hub {
registry: Arc<OperationRegistry>,
aggregated_env: Arc<RwLock<PeerCompositeEnv>>,
dispatcher: Dispatcher,
identity_provider: Arc<dyn IdentityProvider>,
}
Construction:
impl Hub {
pub fn new(
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
) -> Self {
let base: Arc<dyn OperationEnv + Send + Sync> =
Arc::new(LocalOperationEnv::new(Arc::clone(®istry)));
let aggregated_env = Arc::new(RwLock::new(PeerCompositeEnv::new(base)));
let dispatcher = Dispatcher::new(Arc::clone(®istry), Arc::clone(&identity_provider))
.with_aggregated_env(Arc::clone(&aggregated_env));
Self {
registry,
aggregated_env,
dispatcher,
identity_provider,
}
}
/// The shared aggregated PeerCompositeEnv. The assembly layer wires
/// this into CallAdapter::with_aggregated_env so every call's
/// compose_root_env sees all connected workers.
pub fn aggregated_env(&self) -> &Arc<RwLock<PeerCompositeEnv>> {
&self.aggregated_env
}
}
The Hub exposes builder methods for optional hooks
(with_session_source, with_ownership_provider,
with_timeout) that delegate to the Dispatcher.
Transport
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 — all owned by AlknetEndpoint
via builder methods (ADR-083):
| Builder method | Transport | What it carries |
|---|---|---|
with_quinn |
QUIC (quinn) | Channels connections from workers with QUIC reachability |
with_iroh |
QUIC (iroh, relay-assisted) | Channels connections from workers behind NAT |
with_tcp_tls |
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) |
All accept loops run inside endpoint.run() and feed the same dispatch
path. 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 dispatch path the quinn endpoint uses. After ADR-083,
TCP+TLS is a first-class owned transport on AlknetEndpoint — the hub
hands a TcpListener + TlsAcceptor to the endpoint via
with_tcp_tls(listener, acceptor), and the endpoint runs the accept
loop inside run() alongside the quinn/iroh loops. No external sibling
loop; the endpoint owns all its accept loops and shutdown() stops them
all.
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):
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:
/// 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,
}
impl WorkerConnectedCallback {
pub fn new(hub: Arc<Hub>, config: FromCallConfig) -> Self {
Self { hub, config }
}
pub(crate) async fn on_connected(
&self,
connection: &CallConnection,
) -> Result<PeerId, HubError> {
let peer_id = connection.identity()
.map(|id| id.id.clone())
.ok_or(HubError::NoPeerIdentity)?;
let bundles = from_call(connection, self.config.clone()).await?;
connection.register_imported_all(bundles);
self.hub.aggregated_env
.write()
.expect("aggregated env lock poisoned")
.attach_peer(peer_id.clone(), connection.overlay_env());
Ok(peer_id)
}
pub(crate) fn on_disconnected(&self, peer_id: &PeerId) {
self.hub.on_worker_disconnected(peer_id);
}
}
The callback is wired into ChannelsAdapter (the channels substrate)
via a builder method. The ChannelsAdapter::handle flow becomes:
- Channel 0 (
alknet/call) is preinstalled (ADR-072). The channel-0Connectionis handed to theCallAdapter. - Identity resolution — two paths depending on transport (see
"Identity over transports"):
- Fingerprint path (QUIC + raw key, or a transport with a
client cert): the
AuthContextcarried intoChannelsAdapter::handlecarries the peer's TLS fingerprint. TheCallAdapterresolves it viaresolve_from_fingerprint. - Bearer-token path (TCP+TLS with no client cert, WebTransport,
WebSocket): the
CallAdapteron channel 0 extractsauth_tokenfrom the first call frame (auth.md) and resolves it viaresolve_from_token. The transport carries no identity of its own — the call protocol's first frame does.
- Fingerprint path (QUIC + raw key, or a transport with a
client cert): the
- The
CallConnectionis constructed with the resolved identity. - Invoke
on_connected— runsfrom_call, registers bundles, attaches peer to aggregated env. - Run the call-protocol dispatch loop on channel 0; the demux loop continues on the other channels.
- On disconnect, invoke
on_disconnected— detaches peer from aggregated env.
The assembly layer constructs the callback and passes it to
ChannelsAdapter:
let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::new());
let channels_adapter = ChannelsAdapter::new(Arc::clone(®istry), /* ... */)
.with_worker_connected_callback(callback);
// Register channels_adapter on alknet/channels in the HandlerRegistry.
// The endpoint dispatches alknet/channels connections to it — whether
// they arrived over quinn, iroh, or TCP+TLS (all owned by the endpoint).
Identity over transports
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.
| 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:
- The hub provisions a worker instance (docker, vast.ai, runpod —
platform-specific) and supplies a registration token via
onStartCMD(or equivalent). - The instance downloads the worker binary.
- The instance generates an Ed25519 key pair (its future identity).
- The instance POSTs to the hub's HTTP registration endpoint over TCP+TLS, sending its public key and the registration token.
- The hub validates the token, creates a
PeerEntryfor the worker recording both the fingerprint (from the public key) and anauth_token_hash(from a session token the hub issues), and returns the session credential. ThePeerEntryis the mixed-fingerprint shape from ADR-034 §3 — fingerprint for the QUIC path,auth_token_hashfor the TCP+TLS path. - 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
PeerEntryand the samePeerId. 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:
impl Hub {
/// 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>,
dial: F,
config: FromCallConfig,
backoff: BackoffConfig,
) -> 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
CallCredentialspair. 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
pub struct BackoffConfig {
pub initial: Duration,
pub max: Duration,
pub multiplier: f64,
}
impl BackoffConfig {
pub fn delay_for(&self, retries: u32) -> Duration {
let delay = self.initial.as_millis() as f64
* self.multiplier.powi(retries as i32);
let delay_ms = delay.min(self.max.as_millis() as f64) as u64;
Duration::from_millis(delay_ms)
}
}
impl Default for BackoffConfig {
fn default() -> Self {
Self {
initial: Duration::from_secs(1),
max: Duration::from_secs(60),
multiplier: 2.0,
}
}
}
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).
HubError
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum HubError {
#[error("worker connection has no resolved peer identity")]
NoPeerIdentity,
#[error("from_call discovery failed: {0}")]
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 anAccessControldecision on the assembly layer's curated ops — the hub does not add a separate worker-auth layer. - Worker-specific routing policy.
PeerRef::Anyuses insertion-order first-match (ADR-029 §2). A richerRoutingPolicy(round-robin, least-loaded) is a future extension behind the samePeerRefenum. - 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 (Hub struct deps)
├── alknet-channels-call (ChannelClient, ChannelsAdapter, ChannelManager,
│ ChannelBidiStreamSource)
├── alknet-call (CallAdapter, Dispatcher, PeerCompositeEnv,
│ from_call, FromCallConfig, AdapterError, ClientError)
├── alknet-http (HttpAdapter — for the registration endpoint and browser access)
├── alknet-core (IdentityProvider, Connection, OperationRegistry,
│ HandlerRegistry, AuthContext)
├── tokio (spawn, time::sleep)
└── tracing (logging)
(assembly-layer deps — used by the hub's composition code, not by
the Hub struct itself)
├── alknet-tls (TlsServerConfig — builds the raw-key + X.509/ACME
│ configs handed to the endpoint's transports)
└── alknet-core [tcp feature] (with_tcp_tls — the TCP+TLS accept loop)
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.
alknet-tls is an assembly-layer dependency, not a Hub struct
dependency: the Hub struct holds no TlsServerConfig, but the hub's
composition code (the assembly-layer wiring shown in "Assembly layer
integration" below) builds the TlsServerConfigs and hands them to the
endpoint's transports via for_quinn() / for_tcp_tls(). This
distinction matters: a consumer that uses Hub with externally-built
transports does not pull alknet-tls through the Hub type, only
through the assembly wiring.
Assembly layer integration
A downstream hub (alkapi) uses alknet-hub like this:
// 1. Build the curated registry (Layer 0)
let registry = OperationRegistryBuilder::new()
.with_local(agent_chat_spec(), agent_chat_handler, ...)
.with_local(services_list_spec(), services_list_handler, ...)
// ... other curated ops
.build();
let registry = Arc::new(registry);
// 2. Create the hub
let hub = Arc::new(Hub::new(
Arc::clone(®istry),
Arc::clone(&identity_provider),
).with_ownership_provider(ownership_provider));
// 3. Register the ChannelsAdapter on alknet/channels. The endpoint
// dispatches alknet/channels connections to this adapter — whether
// they arrived over quinn, iroh, or TCP+TLS.
let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::new());
let channels_adapter = ChannelsAdapter::new(/* ... */)
.with_worker_connected_callback(callback);
registry.register(b"alknet/channels", Arc::new(channels_adapter));
// 4. Register the HttpAdapter on h2/http1.1. The endpoint dispatches
// h2/http1.1 connections (arriving over TCP+TLS) 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. Build the quinn endpoint (raw-key config for native clients)
let quinn_endpoint = raw_key_tls.for_quinn()?.into_endpoint(listen_addr)?;
// 6. Build the TCP+TLS listener (X.509/ACME config for HTTPS)
let tcp_listener = TcpListener::bind(registration_addr).await?;
let tls_acceptor = x509_tls.for_tcp_tls();
// 7. Construct the endpoint with all owned transports, then run.
// TCP+TLS is a first-class owned transport (ADR-083) — the endpoint
// runs its accept loop inside run() alongside quinn/iroh. No external
// sibling loop; shutdown() stops them all.
let endpoint = Arc::new(
AlknetEndpoint::new(registry, dynamic, identity_provider, drain_timeout)
.with_quinn(quinn_endpoint)
.with_tcp_tls(tcp_listener, tls_acceptor),
);
endpoint.clone().run().await;
// 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(
|| 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(),
);
// 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.
Design Decisions
| Decision | ADR | Summary |
|---|---|---|
| Hub relay — translate, not transparently forward | ADR-079 | Translate channel/open on channel 0 with forwarded_for; byte-forward data channels with channel_id rewrite |
| Aggregated peer-env wiring | ADR-067 | Dispatcher::with_aggregated_env hook; compose_root_env reads shared env |
| PeerCompositeEnv::peer_operations | ADR-068 | list_operation_names trait method; PeerCompositeEnv override |
| from_call is manual | ADR-069 | from_call is a free function; the hub calls it after connect |
| Peer-graph routing model | ADR-029 | Peer-keyed overlays, PeerRef routing, AccessControl-based peer auth |
| PeerEntry and Identity.id | ADR-030 | PeerId = Identity.id = PeerEntry.peer_id (stable) |
| Three peer roles | ADR-034 | Hub = role-3 PeerEntry (mixed fingerprints); browsers not peers; bearer-token identity over TCP/WebTransport |
| ChannelClient — transport-agnostic | ADR-080 | from_connection primary, connect_quic convenience; the dial path the hub uses |
| Channels transport-agnostic | ADR-071 | Substrate modes; Connection::from_stream/from_bidi (ADR-065) — the substrate the hub relays |
| TCP+TLS as first-class owned transport | ADR-083 | with_tcp_tls(listener, acceptor) — TCP+TLS is owned by the endpoint, not a sibling loop; supersedes ADR-010 Am. 1 |
| Channel 0 pre-negotiated | ADR-072 | Channel 0 = alknet/call; the CallAdapter runs here |
| Channel lifecycle operations | ADR-073 | channel/open/close/control/resources/subscribe — what the hub translates |
Open Questions
See open-questions.md for full details.
- OQ-58 (open): Worker registration flow — the enrollment-token
model, the HTTP registration endpoint shape, and the
register_workerAPI. Decision-ready in shape (HTTP POST, token in,PeerEntrycreated, 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 pollingconnection().accept_bi()untilConnectionClosed. Aclosed()method onCallConnectionis the target resolution. - OQ-53 (open):
BackoffConfigdefaults — 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. TheWorkerConnectedCallbackis the committed design.
References
- channel-client.md —
ChannelClient(from_connection/connect_quic— the dial path) - channels-adapter.md —
ChannelsAdapter,ChannelManager, the accept path - channel-operations.md —
channel/open/close/control, the hub relay contract - client-and-adapters.md —
CallClient,from_call,OperationAdapter - call-protocol.md —
CallAdapter,Dispatcher,CallConnection - operation-registry.md —
OperationRegistry,OperationRegistryBuilder,OperationEnv - http-server.md —
HttpAdapter(the registration endpoint and browser HTTP access) - 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
- ADR-079: Hub Relay — Translate, Not Transparently Forward
- ADR-080: ChannelClient (transport-agnostic
from_connection) - ADR-082: alknet-tls extraction (
TlsServerConfig— shared across quinn + TCP+TLS) - ADR-083: Endpoint as multi-transport accept-loop runner (
with_tcp_tls— TCP+TLS owned by the endpoint; the hub composes transports and handlers) - alkapi hub.md — the first hub consumer, the concrete use case that informed this crate
- alkapi ADR-011 — the downstream aggregation decision