docs(arch): TCP+TLS as first-class owned transport — resolves OQ-60, dissolves OQ-61

ADR-083 revised: TCP+TLS moves from an external sibling loop calling
public dispatch to a first-class owned transport via
with_tcp_tls(listener, acceptor), running inside run() alongside the
quinn and iroh accept loops. The endpoint owns all its accept loops;
shutdown() stops them all. The multi-owner shutdown problem (OQ-61)
does not arise — dissolved.

The reason TCP+TLS was structurally excluded (ADR-010 Am. 1: the
endpoint built transports internally, TCP+TLS couldn't fit) is gone
after ADR-083 — the endpoint no longer builds transports; it runs
accept loops on whatever it's given. TCP+TLS is a listener transport,
same shape as quinn and iroh. ADR-010 Amendment 2 supersedes Am. 1's
struct-level exclusion.

dispatch stays public — but for genuinely external shapes (SSH channels,
future WebTransport streams), which are connection-internal multiplexing,
not listener transports. The listener-vs-multiplexing distinction is now
explicit.

OQ-60 resolved: the TCP+TLS loop lives in alknet-core behind a tcp
feature (owned by the endpoint); builder functions are inlined by the
assembly layer. A alknet-transport crate was rejected — it would contain
only trivial builders; the real component (the loop) is in core. Hub-
specific composition lives in the hub crate; transport runtimes that any
node might need live in core.

Updated: ADR-010 (Amendment 2), ADR-082 (TCP+TLS loop location), ADR-083
(revised), core/endpoint.md (struct + dispatch + shutdown), hub/README.md
(transport table + assembly example + stale sibling references),
tls/README.md (endpoint section + TCP+TLS loop location + references),
open-questions.md (OQ-60 resolved, OQ-61 dissolved).

Review: zero critical issues, five warnings fixed (stale hub README
prose, stale core endpoint.md struct/dispatch listings, stale ADR-082
TCP+TLS loop location, stale TLS README reference entry, hub front-matter
date).
This commit is contained in:
glm-5.2 committed 2026-07-14 08:58:50 +00:00
1 parent 81bde6f28f
commit 2abe8f1872
10 files changed
+524 -308

No files matched your search

+2 -2
View File
@@ -194,7 +194,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| [007](decisions/007-bistream-type-definition.md) | BiStream Type Definition | Accepted | | [007](decisions/007-bistream-type-definition.md) | BiStream Type Definition | Accepted |
| [008](decisions/008-secret-service-integration.md) | Vault Integration Point | Accepted | | [008](decisions/008-secret-service-integration.md) | Vault Integration Point | Accepted |
| [009](decisions/009-one-way-door-decision-framework.md) | One-Way Door Decision Framework | Accepted | | [009](decisions/009-one-way-door-decision-framework.md) | One-Way Door Decision Framework | Accepted |
| [010](decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Accepted (Amendment 1 superseded by ADR-083 — TCP+TLS dispatch is first-class via public `dispatch`) | | [010](decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Accepted (Amendment 1 superseded by ADR-083 — TCP+TLS is a first-class owned transport via `with_tcp_tls`) |
| [011](decisions/011-authcontext-structure.md) | AuthContext Structure and Resolution Flow | Accepted | | [011](decisions/011-authcontext-structure.md) | AuthContext Structure and Resolution Flow | Accepted |
| [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Accepted | | [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Accepted |
| [013](decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Accepted | | [013](decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Accepted |
@@ -267,7 +267,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | Accepted | | [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | Accepted |
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted | | [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted |
| [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Proposed (amended — endpoint signature superseded by ADR-083) | | [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Proposed (amended — endpoint signature superseded by ADR-083) |
| [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Pure Accept-Loop Runner with Public Dispatch | Proposed | | [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner with Public Dispatch | Proposed (revised — TCP+TLS is an owned transport, not external) |
## Open Questions ## Open Questions
+66 -36
View File
@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-09 last_updated: 2026-07-14
--- ---
# Endpoint # Endpoint
@@ -15,17 +15,26 @@ The central runtime type. Manages one or more QUIC connection sources, each feed
```rust ```rust
pub struct AlknetEndpoint { pub struct AlknetEndpoint {
// QUIC connection sources — both optional, both can be active simultaneously // One or more connection sources — all optional, all can be active simultaneously
quinn: Option<quinn::Endpoint>, // Public QUIC+TLS quinn: Option<quinn::Endpoint>, // Public QUIC+TLS
iroh: Option<iroh::Endpoint>, // P2P relay-assisted iroh: Option<iroh::Endpoint>, // P2P relay-assisted
#[cfg(feature = "tcp")]
tcp_tls: Option<TcpTlsListener>, // TCP+TLS (TcpListener + TlsAcceptor)
handlers: Arc<HandlerRegistry>, handlers: Arc<HandlerRegistry>,
dynamic: Arc<ArcSwap<DynamicConfig>>, dynamic: Arc<ArcSwap<DynamicConfig>>,
identity_provider: Arc<dyn IdentityProvider>, identity_provider: Arc<dyn IdentityProvider>,
shutdown: watch::Receiver<bool>, shutdown_tx: watch::Sender<bool>,
shutdown_rx: watch::Receiver<bool>,
drain_timeout: Duration,
} }
``` ```
See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) for
the full design (the endpoint takes no `StaticConfig` or TLS config;
transports are built by the assembly layer and handed via
`with_quinn` / `with_iroh` / `with_tcp_tls`).
### Why multiple connection sources? ### Why multiple connection sources?
A node can be reachable through different paths depending on its network context: A node can be reachable through different paths depending on its network context:
@@ -37,29 +46,28 @@ A node can be reachable through different paths depending on its network context
These are not interchangeable transports — they are **complementary connectivity modes**. A node behind NAT that also has a public IP can use both simultaneously. Both produce QUIC connections that dispatch through the same `HandlerRegistry` by ALPN string. These are not interchangeable transports — they are **complementary connectivity modes**. A node behind NAT that also has a public IP can use both simultaneously. Both produce QUIC connections that dispatch through the same `HandlerRegistry` by ALPN string.
### TCP is NOT an endpoint struct concern (but CAN dispatch through the registry) ### TCP+TLS is a first-class owned transport
Bare TCP (SSH over port 22) does not use QUIC or ALPN. TCP access is not TCP+TLS is a listener transport, same shape as quinn and iroh. The
owned by the `AlknetEndpoint` struct — there is no `tcp: endpoint owns it via `with_tcp_tls(listener, acceptor)` (behind a `tcp`
Option<TcpListener>` field. The endpoint manages QUIC connection sources feature) and runs its accept loop inside `run()` — `tcp.accept()` →
(quinn + iroh) only. `tls.accept()` → extract ALPN + fingerprint → `Connection::from_bidi` →
`dispatch`. No external sibling loop, no duplicated dispatch logic.
This does **not** mean TCP+TLS can't participate in ALPN dispatch. Since This reverses ADR-010's original "TCP is not an endpoint struct concern."
[ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md), The reason TCP was excluded — the endpoint built transports internally,
`Connection::from_bidi(tls_stream, alpn, remote_addr)` constructs a and TCP+TLS couldn't fit that shape — is gone (ADR-083: the endpoint no
`Connection` from any `TlsStream<TcpStream>` (or any `AsyncRead + longer builds transports; it runs accept loops on whatever it's given).
AsyncWrite` pair). A TCP+TLS accept loop built *outside* the endpoint (by TCP+TLS fits the same listener shape as quinn and iroh.
the assembly layer or a handler) can wrap each TLS stream as a
`Connection` and dispatch through the **same `HandlerRegistry`** the
endpoint uses, by the ALPN negotiated in the TLS handshake. This is not a
parallel listener bypassing the core — it's the same ALPN dispatch, over
a non-QUIC transport. `HttpAdapter`, `TtyAdapter`, and the call handler
all work over the single stream unchanged (ADR-065's yield-once
`accept_bi` contract). The TCP+TLS accept loop itself is a follow-up
commit, not part of `AlknetEndpoint`; the primitive it needs
(`from_bidi`) is in place.
The reference implementation's TCP transport (`alknet-main/crates/alknet-core/src/transport/tcp.rs`) is SSH-specific. It doesn't generalize to the ALPN model. The `dispatch` method is public for transports the endpoint **can't
own** — SSH channels (one connection, many channels with different
ALPNs — a multiplexing shape, not a listener) and future WebTransport
streams (one QUIC connection, many WT streams). These are
connection-internal multiplexing, not listener transports.
See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
for the full design.
## HandlerRegistry ## HandlerRegistry
@@ -136,24 +144,39 @@ See iroh's `protocol.rs` (`/workspace/iroh/iroh/src/protocol.rs`) for the refere
### Dispatch function (shared) ### Dispatch function (shared)
The public `dispatch` method is the shared dispatch path for every
transport — the endpoint's own accept loops (quinn, iroh, TCP+TLS) call
it after transport-specific extraction, and external dispatch callers
(SSH channels, future WebTransport streams) call it after their own
extraction.
``` ```
fn dispatch(connection) { pub fn dispatch(&self, connection: Connection, alpn: Vec<u8>,
let alpn = connection.alpn(); fingerprint: Option<String>, remote_addr: Option<SocketAddr>) {
match handlers.get(alpn) { // ACME guard (transport-agnostic — ADR-083)
if alpn == b"acme-tls/1" {
connection.close(0, "acme done");
return;
}
match handlers.get(&alpn) {
Some(handler) => { Some(handler) => {
let auth = AuthContext::from_connection(&connection); let auth = build_auth_context(&alpn, remote_addr, fingerprint, &identity_provider);
let conn = Connection::from_quinn(connection); // or from_iroh
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handler.handle(conn, &auth).await { if let Err(e) = handler.handle(connection, &auth).await {
// log error, connection closes // log error, connection closes
} }
}); });
} }
None => connection.close(0u32, "no handler"), None => { connection.close(0, "no handler"); /* log warning */ }
} }
} }
``` ```
Synchronous (non-async): spawns the handler on its own task and returns
immediately. The caller's accept loop is not blocked. See
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) for the
full dispatch contract.
### What the accept loops do NOT do ### What the accept loops do NOT do
- **No byte-peeking**: ALPN negotiation handles protocol detection. The old `stealth` module's `detect_protocol()` is unnecessary. - **No byte-peeking**: ALPN negotiation handles protocol detection. The old `stealth` module's `detect_protocol()` is unnecessary.
@@ -260,11 +283,14 @@ impl AlknetEndpoint {
} }
``` ```
- `shutdown_sender()` returns a clone of the shutdown channel sender. Call `send(true)` to signal shutdown. - `shutdown_sender()` returns a clone of the shutdown channel sender. Call `send(true)` to signal shutdown. The assembly layer uses this for any external dispatch callers (SSH, future WT); the endpoint's own loops are signaled internally.
- `shutdown()` signals all accept loops to stop, waits for in-flight connections with a drain timeout (default: 2 seconds), then forcefully closes remaining connections. - `shutdown()` signals all owned accept loops (quinn, iroh, TCP+TLS) to stop, waits for in-flight dispatched handlers with a drain timeout, then forcefully closes remaining connections. One owner, one shutdown — no external loop coordination (ADR-083).
- SIGTERM/SIGINT are wired to the shutdown channel by the CLI binary. - SIGTERM/SIGINT are wired to the shutdown channel by the CLI binary.
The drain timeout is configurable via `StaticConfig::drain_timeout`. The drain timeout is passed to `AlknetEndpoint::new()` directly (as
`drain_timeout: Duration`), not via `StaticConfig` — the endpoint no
longer takes `StaticConfig` (ADR-083). The assembly layer reads
`StaticConfig::drain_timeout` and passes it in.
## Error Handling ## Error Handling
@@ -308,9 +334,11 @@ Non-fatal errors within a handler. See [core-types.md](core-types.md) for detail
| Decision | ADR | Summary | | Decision | ADR | Summary |
|----------|-----|---------| |----------|-----|---------|
| Multi-connectivity endpoint (quinn + iroh) | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Both optional, both feed same ALPN router | | Multi-connectivity endpoint (quinn + iroh + TCP+TLS) | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md), [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) | All three optional, all feed same dispatch; endpoint owns all accept loops |
| Endpoint takes no TLS config; assembly layer builds transports | [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) | `new()` takes `drain_timeout` + builder methods, no `StaticConfig` or `Arc<TlsServerConfig>` |
| TCP+TLS is a first-class owned transport | [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) | `with_tcp_tls(listener, acceptor)` — reverses ADR-010's "TCP is not an endpoint struct concern" |
| Public `dispatch` for SSH/WT (multiplexing shapes) | [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) | `dispatch` is public for connection-internal multiplexing, not for listener transports |
| Static handler registration | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Two-way door, start static, add ArcSwap later | | Static handler registration | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Two-way door, start static, add ArcSwap later |
| TCP is not an endpoint struct concern (but dispatches via `from_stream`) | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md), [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `AlknetEndpoint` is QUIC-only (no `tcp` field); a TCP+TLS loop outside the endpoint wraps streams via `from_bidi` and shares the registry |
| No byte-peeking, ALPN dispatch only | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | TLS layer handles protocol detection | | No byte-peeking, ALPN dispatch only | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | TLS layer handles protocol detection |
| Stealth mode = HTTP handler on standard ALPNs | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Decoy via ALPN routing, not byte-peek | | Stealth mode = HTTP handler on standard ALPNs | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Decoy via ALPN routing, not byte-peek |
| Network identity ≠ auth identity | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | TLS cert/NodeId = network, SSH key/token = auth | | Network identity ≠ auth identity | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | TLS cert/NodeId = network, SSH key/token = auth |
@@ -322,4 +350,6 @@ See [open-questions.md](../../open-questions.md) for full details.
- **OQ-04**: Resolved — HandlerRegistry is static at startup. - **OQ-04**: Resolved — HandlerRegistry is static at startup.
- **OQ-05**: Resolved — multi-connectivity endpoint with quinn + iroh, both feature-gated. - **OQ-05**: Resolved — multi-connectivity endpoint with quinn + iroh, both feature-gated.
- **OQ-12**: Resolved — two distinct TLS identity use cases: RFC 7250 raw keys (default, P2P) and X.509 certs (domain-hosted, browsers). ACME auto-provisioning designed in [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md); RawKey decoupled from the `iroh` feature (available in quinn-only builds). - **OQ-12**: Resolved — two distinct TLS identity use cases: RFC 7250 raw keys (default, P2P) and X.509 certs (domain-hosted, browsers). ACME auto-provisioning designed in [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md); RawKey decoupled from the `iroh` feature (available in quinn-only builds).
- **OQ-60**: Resolved — transport construction is inlined by the assembly layer; the TCP+TLS loop lives in `alknet-core` behind a `tcp` feature as an owned transport. See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md).
- **OQ-61**: Dissolved — the multi-owner shutdown problem does not arise; the endpoint owns all its accept loops. See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md).
+59 -49
View File
@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-07-13 last_updated: 2026-07-14
--- ---
# alknet-hub # alknet-hub
@@ -42,11 +42,11 @@ welded to a dial. See "Transport" below.
### What the hub provides ### What the hub provides
1. **Multi-transport endpoint** — accepts channels connections over 1. **Multi-transport endpoint** — accepts channels connections over
QUIC (via the quinn `AlknetEndpoint`) and over TCP+TLS (via an QUIC and over TCP+TLS (both owned transports on `AlknetEndpoint` via
accept loop that wraps each `TlsStream<TcpStream>` as a `Connection` `with_quinn` / `with_tcp_tls`, per ADR-083). Both feed the same
with `from_bidi`, per ADR-065/010). Both feed the same dispatch path. Also serves HTTP (`h2`/`http/1.1`) over TCP+TLS for
`HandlerRegistry`. Also serves HTTP (`h2`/`http/1.1`) over TCP+TLS registration and browser access. All three coexist; the endpoint owns
for registration and browser access. All three coexist. all accept loops and `shutdown()` stops them all.
2. **Peer lifecycle** — accept, dial, disconnect, reconnect with 2. **Peer lifecycle** — accept, dial, disconnect, reconnect with
backoff. Identity resolution via `IdentityProvider` (fingerprint or backoff. Identity resolution via `IdentityProvider` (fingerprint or
@@ -81,7 +81,10 @@ The hub pattern requires wiring that `alknet-channels` and
- The hub accepts channels connections over **multiple transports** - The hub accepts channels connections over **multiple transports**
simultaneously. The channels crate is transport-agnostic (ADR-071, simultaneously. The channels crate is transport-agnostic (ADR-071,
ADR-065); the hub is where the multi-transport accept loop lives. ADR-065); the hub composes the multi-transport endpoint
(`AlknetEndpoint` with `with_quinn` / `with_tcp_tls`, ADR-083). The
accept loops themselves live in `alknet-core`; the hub provides the
handlers and wiring.
- The hub relays channels between legs (ADR-079) — terminating - The hub relays channels between legs (ADR-079) — terminating
channel 0 on each leg, translating `channel/open`, byte-forwarding channel 0 on each leg, translating `channel/open`, byte-forwarding
data channels. The channels crate is ALPN-blind and does not know it data channels. The channels crate is ALPN-blind and does not know it
@@ -179,30 +182,34 @@ The `Hub` exposes builder methods for optional hooks
The hub accepts and dials channels connections over any transport The hub accepts and dials channels connections over any transport
the channels protocol supports (ADR-071). In practice, a hub runs the channels protocol supports (ADR-071). In practice, a hub runs
multiple accept loops simultaneously: multiple accept loops simultaneously — all owned by `AlknetEndpoint`
via builder methods (ADR-083):
| Endpoint | Transport | What it carries | | Builder method | Transport | What it carries |
|----------|-----------|-----------------| |----------------|-----------|-----------------|
| Quinn `AlknetEndpoint` | QUIC (quinn/iroh) | Channels connections from workers with QUIC reachability | | `with_quinn` | QUIC (quinn) | 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 | | `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) | | (Future) WebTransport | WebTransport | Browser bidirectional path (deferred per ADR-044; WebSocket is the v1 browser path) |
All accept loops feed into the same `HandlerRegistry`. The hub's All accept loops run inside `endpoint.run()` and feed the same dispatch
`HttpAdapter` serves `h2`/`http/1.1` over the TCP+TLS path for path. The hub's `HttpAdapter` serves `h2`/`http/1.1` over the TCP+TLS
registration and browser access; the `ChannelsAdapter` (registered on path for registration and browser access; the `ChannelsAdapter`
the hub's `HandlerRegistry`) serves (registered on the hub's `HandlerRegistry`) serves `alknet/channels`
`alknet/channels` over the QUIC path (and over TCP+TLS when a worker over the QUIC path (and over TCP+TLS when a worker dials
dials channels-over-TCP). Both ALPNs are registered on the same channels-over-TCP). Both ALPNs are registered on the same registry;
registry; the TLS handshake on each connection negotiates the ALPN the TLS handshake on each connection negotiates the ALPN and dispatches
and dispatches to the right adapter. to the right adapter.
The TCP+TLS accept loop constructs a `Connection` per accepted The TCP+TLS accept loop constructs a `Connection` per accepted
`TlsStream<TcpStream>` via `Connection::from_bidi` (ADR-065) and `TlsStream<TcpStream>` via `Connection::from_bidi` (ADR-065) and hands
hands it to the same `HandlerRegistry::dispatch` the quinn endpoint it to the same dispatch path the quinn endpoint uses. After ADR-083,
uses. This is the accept-loop-outside-the-endpoint pattern from TCP+TLS is a first-class owned transport on `AlknetEndpoint` — the hub
ADR-010's Amendment 1: the `AlknetEndpoint` struct stays QUIC-only hands a `TcpListener` + `TlsAcceptor` to the endpoint via
(no `tcp` field), and the TCP+TLS loop is a sibling accept source `with_tcp_tls(listener, acceptor)`, and the endpoint runs the accept
that shares the registry. The hub is where that sibling loop lives. 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 #### Dial (outbound workers) — transport-agnostic
@@ -330,8 +337,8 @@ let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::ne
let channels_adapter = ChannelsAdapter::new(Arc::clone(&registry), /* ... */) let channels_adapter = ChannelsAdapter::new(Arc::clone(&registry), /* ... */)
.with_worker_connected_callback(callback); .with_worker_connected_callback(callback);
// Register channels_adapter on alknet/channels in the HandlerRegistry. // Register channels_adapter on alknet/channels in the HandlerRegistry.
// Both the quinn endpoint and the TCP+TLS accept loop dispatch // The endpoint dispatches alknet/channels connections to it — whether
// alknet/channels connections to it. // they arrived over quinn, iroh, or TCP+TLS (all owned by the endpoint).
``` ```
### Identity over transports ### Identity over transports
@@ -596,37 +603,38 @@ let hub = Arc::new(Hub::new(
Arc::clone(&identity_provider), Arc::clone(&identity_provider),
).with_ownership_provider(ownership_provider)); ).with_ownership_provider(ownership_provider));
// 3. Register the ChannelsAdapter on alknet/channels. Both the quinn // 3. Register the ChannelsAdapter on alknet/channels. The endpoint
// endpoint and the TCP+TLS accept loop dispatch alknet/channels // dispatches alknet/channels connections to this adapter — whether
// connections to this adapter. // they arrived over quinn, iroh, or TCP+TLS.
let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::new()); let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::new());
let channels_adapter = ChannelsAdapter::new(/* ... */) let channels_adapter = ChannelsAdapter::new(/* ... */)
.with_worker_connected_callback(callback); .with_worker_connected_callback(callback);
registry.register(b"alknet/channels", Arc::new(channels_adapter)); registry.register(b"alknet/channels", Arc::new(channels_adapter));
// 4. Register the HttpAdapter on h2/http1.1. The TCP+TLS accept loop // 4. Register the HttpAdapter on h2/http1.1. The endpoint dispatches
// dispatches h2/http1.1 connections to this adapter (registration // h2/http1.1 connections (arriving over TCP+TLS) to this adapter
// endpoint, browser access, stealth decoy). // (registration endpoint, browser access, stealth decoy).
let http_adapter = HttpAdapter::new(/* ... */); let http_adapter = HttpAdapter::new(/* ... */);
registry.register(b"h2", Arc::new(http_adapter.clone())); registry.register(b"h2", Arc::new(http_adapter.clone()));
registry.register(b"http/1.1", Arc::new(http_adapter)); registry.register(b"http/1.1", Arc::new(http_adapter));
// 5. Start the QUIC accept loop (workers with QUIC reachability) // 5. Build the quinn endpoint (raw-key config for native clients)
let quinn_endpoint = AlknetEndpoint::new(/* ... */, Arc::clone(&registry)); let quinn_endpoint = raw_key_tls.for_quinn()?.into_endpoint(listen_addr)?;
quinn_endpoint.run().await;
// 6. Start the TCP+TLS accept loop (registration, browser access, // 6. Build the TCP+TLS listener (X.509/ACME config for HTTPS)
// channels-over-TCP from workers)
let tcp_listener = TcpListener::bind(registration_addr).await?; let tcp_listener = TcpListener::bind(registration_addr).await?;
tokio::spawn(async move { let tls_acceptor = x509_tls.for_tcp_tls();
loop {
let (stream, _) = tcp_listener.accept().await?; // 7. Construct the endpoint with all owned transports, then run.
let tls_stream = tls_acceptor.accept(stream).await?; // TCP+TLS is a first-class owned transport (ADR-083) — the endpoint
let alpn = tls_stream.alpn()?; // runs its accept loop inside run() alongside quinn/iroh. No external
let conn = Connection::from_bidi(tls_stream, alpn, /* remote_addr */); // sibling loop; shutdown() stops them all.
registry.dispatch(conn).await; // routes by ALPN to HttpAdapter or ChannelsAdapter 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 // 7. Dial outbound workers (hub dials workers). The closure produces a
// channels Connection — the hub's supervise_worker calls // channels Connection — the hub's supervise_worker calls
@@ -677,7 +685,7 @@ into `CallAdapter::with_aggregated_env`.
| 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 | | 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 | | 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 | | 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 | | TCP+TLS as first-class owned transport | [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) | `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](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 = `alknet/call`; the `CallAdapter` runs here | | 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 | | Channel lifecycle operations | [ADR-073](../../decisions/073-channel-lifecycle-operations.md) | `channel/open`/`close`/`control`/`resources/subscribe` — what the hub translates |
@@ -731,6 +739,8 @@ See [open-questions.md](../../open-questions.md) for full details.
- ADR-069: from_call Is a Manual Free Function - ADR-069: from_call Is a Manual Free Function
- ADR-079: Hub Relay — Translate, Not Transparently Forward - ADR-079: Hub Relay — Translate, Not Transparently Forward
- ADR-080: ChannelClient (transport-agnostic `from_connection`) - 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](/workspace/@alkdev/alkapi/docs/architecture/hub.md) — - alkapi [hub.md](/workspace/@alkdev/alkapi/docs/architecture/hub.md) —
the first hub consumer, the concrete use case that informed this the first hub consumer, the concrete use case that informed this
crate crate
+45 -35
View File
@@ -268,8 +268,9 @@ production and `rustls::sign` in the test helper `build_ed25519_spki_der`
`AlknetEndpoint::new()` currently builds `TlsSetup` internally. After `AlknetEndpoint::new()` currently builds `TlsSetup` internally. After
the refactor (see [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)), the refactor (see [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)),
the endpoint takes **no TLS config at all** — it is a pure accept-loop the endpoint takes **no TLS config at all** — it is a multi-transport
runner with a public `dispatch` method: accept-loop runner. TCP+TLS is an owned transport (via `with_tcp_tls`),
not an external loop:
```rust ```rust
impl AlknetEndpoint { impl AlknetEndpoint {
@@ -283,6 +284,17 @@ impl AlknetEndpoint {
pub fn with_quinn(mut self, endpoint: quinn::Endpoint) -> Self; pub fn with_quinn(mut self, endpoint: quinn::Endpoint) -> Self;
pub fn with_iroh(mut self, endpoint: iroh::Endpoint) -> Self; pub fn with_iroh(mut self, endpoint: iroh::Endpoint) -> Self;
/// TCP+TLS is a first-class owned transport — same `run()` loop,
/// same `shutdown()` as quinn/iroh. Feature-gated on `tcp`.
#[cfg(feature = "tcp")]
pub fn with_tcp_tls(
mut self,
listener: tokio::net::TcpListener,
acceptor: tokio_rustls::TlsAcceptor,
) -> Self;
/// Public for SSH channels / future WT (connection-internal
/// multiplexing, not listener transports).
pub fn dispatch( pub fn dispatch(
&self, &self,
connection: Connection, connection: Connection,
@@ -292,32 +304,35 @@ impl AlknetEndpoint {
); );
pub async fn run(self: Arc<Self>); pub async fn run(self: Arc<Self>);
pub async fn shutdown(&self) -> Result<(), EndpointError>;
} }
``` ```
The assembly layer builds the `TlsServerConfig`(s), builds the The assembly layer builds the `TlsServerConfig`(s), builds the
transports (`for_quinn()` → `quinn::Endpoint::server()`, transports (`for_quinn()` → `quinn::Endpoint::server()`,
`for_tcp_tls()` → `TlsAcceptor`, the `Ed25519SecretKey` → iroh), and `for_tcp_tls()` → `TlsAcceptor` paired with a `TcpListener`,
hands the pre-built quinn/iroh endpoints to `AlknetEndpoint` via `Ed25519SecretKey` → iroh), and hands them to `AlknetEndpoint` via
builder methods. A hub serving native clients and browsers holds two builder methods. A hub serving native clients and browsers holds two
`TlsServerConfig`s (raw key + X.509/ACME); the endpoint takes neither — `TlsServerConfig`s (raw key + X.509/ACME); the endpoint takes neither —
it takes the already-built transport endpoints. The TCP+TLS accept it takes the already-built transport endpoints. The TCP+TLS listener
loops (one per config) call `endpoint.dispatch(...)` — the same is owned by the endpoint via `with_tcp_tls`; the endpoint runs its
dispatch path quinn and iroh use. The ACME handle lives on the accept loop inside `run()` and stops it on `shutdown()`. The ACME
`TlsServerConfig`, not the endpoint. handle lives on the `TlsServerConfig`, not the endpoint.
This resolves the single-`Arc<TlsServerConfig>` problem: the endpoint This resolves the single-`Arc<TlsServerConfig>` problem: the endpoint
has no "the TLS config" to take because a hub has two. has no "the TLS config" to take because a hub has two. It also means
shutdown is single-owner — the endpoint owns all its accept loops
(quinn, iroh, TCP+TLS); one `shutdown()` stops them all.
### The TCP+TLS accept loop (out of scope for this crate) ### The TCP+TLS accept loop (out of scope for this crate)
`alknet-tls` provides `for_tcp_tls() -> TlsAcceptor`. The actual TCP `alknet-tls` provides `for_tcp_tls() -> TlsAcceptor`. The actual TCP
accept loop (`TcpListener::accept` → `TlsAcceptor::accept` → accept loop (`TcpListener::accept` → `TlsAcceptor::accept` →
`Connection::from_bidi` → `HandlerRegistry::dispatch`) lives elsewhere — `Connection::from_bidi` → `endpoint.dispatch()`) lives in `alknet-core`
in `alknet-hub` (the hub is the primary multi-transport consumer) or in behind a `tcp` feature, as an owned transport on `AlknetEndpoint` (via
a future `alknet-core` `TcpTlsAcceptor` module. `alknet-tls` is the cert `with_tcp_tls(listener, acceptor)` — see ADR-083). `alknet-tls` is the
provider, not the accept loop. This keeps `alknet-tls` focused on TLS cert provider, not the accept loop. This keeps `alknet-tls` focused on
setup and cert sharing, not transport accept logic. TLS setup and cert sharing, not transport accept logic.
## Crate dependencies (in the dep graph) ## Crate dependencies (in the dep graph)
@@ -331,23 +346,21 @@ alknet-core (loses TLS setup code)
alknet-call (client-side verifier — unchanged) alknet-call (client-side verifier — unchanged)
├── alknet-core (fingerprint.rs) ├── alknet-core (fingerprint.rs)
alknet-http (future: TCP+TLS accept loop)
├── alknet-tls (TlsServerConfig::for_tcp_tls())
├── alknet-core (Connection::from_bidi, HandlerRegistry)
alknet-hub (multi-transport endpoint) alknet-hub (multi-transport endpoint)
├── alknet-tls (TlsServerConfig — shared across quinn + TCP) ├── alknet-tls (TlsServerConfig — shared across quinn + TCP)
├── alknet-channels-call (ChannelClient) ├── alknet-channels-call (ChannelClient)
├── alknet-call (CallAdapter, Dispatcher) ├── alknet-call (CallAdapter, Dispatcher)
├── alknet-http (HttpAdapter) ├── alknet-http (HttpAdapter)
├── alknet-core (AlknetEndpoint, HandlerRegistry, Connection) ├── alknet-core (AlknetEndpoint with quinn + iroh + tcp features, HandlerRegistry, Connection)
``` ```
`alknet-tls` depends on `alknet-core` only. No handler crate depends on `alknet-tls` depends on `alknet-core` only. No handler crate depends on
`alknet-tls` — they depend on `alknet-core` for types and on `alknet-tls` — they depend on `alknet-core` for types and on
`alknet-tls` only indirectly through the assembly layer. The assembly `alknet-tls` only indirectly through the assembly layer. The assembly
layer (the deployment binary) builds the `TlsServerConfig` and passes it layer (the deployment binary) builds the `TlsServerConfig`(s), builds
to the endpoint and the TCP+TLS accept loop. the transport endpoints (quinn/iroh/TCP+TLS), and hands them to
`AlknetEndpoint` via `with_quinn` / `with_iroh` / `with_tcp_tls`
(ADR-083).
## Design Decisions ## Design Decisions
@@ -357,7 +370,7 @@ All design decisions are documented as ADRs in
| ADR | Decision | Summary | | ADR | Decision | Summary |
|-----|----------|---------| |-----|----------|---------|
| [082](../../decisions/082-alknet-tls-extraction.md) | alknet-tls crate extraction | Extract TLS setup from alknet-core/endpoint.rs; `TlsServerConfig` shareable across quinn + TCP+TLS + iroh; one ACME state machine | | [082](../../decisions/082-alknet-tls-extraction.md) | alknet-tls crate extraction | Extract TLS setup from alknet-core/endpoint.rs; `TlsServerConfig` shareable across quinn + TCP+TLS + iroh; one ACME state machine |
| [083](../../decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as accept-loop runner | `AlknetEndpoint` takes no TLS config; assembly layer builds transports from `TlsServerConfig`s; public `dispatch` method; `acme-tls/1` guard moves to shared `dispatch` | | [083](../../decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as multi-transport accept-loop runner | `AlknetEndpoint` takes no TLS config; TCP+TLS is an owned transport (`with_tcp_tls`); `dispatch` public for SSH/WT; `acme-tls/1` guard moves to shared `dispatch` |
## Open Questions ## Open Questions
@@ -370,17 +383,13 @@ See [open-questions.md](../../open-questions.md) for full details.
on `alknet-tls` — a new dep edge. If it stays, core keeps a narrow on `alknet-tls` — a new dep edge. If it stays, core keeps a narrow
`rustls` dep. Decision-ready — the answer depends on whether we want `rustls` dep. Decision-ready — the answer depends on whether we want
core to be `rustls`-free. core to be `rustls`-free.
- **OQ-60** (open): Where does transport construction live? The - **OQ-60** (resolved): Where does transport construction live? The
endpoint-as-accept-loop-runner boundary (ADR-083) commits to TCP+TLS accept loop lives in `alknet-core` behind a `tcp` feature as
construction being outside the endpoint, but the destination — an owned endpoint transport (`with_tcp_tls`). Builder functions are
assembly layer, `alknet-tls` convenience helpers, or a transport inlined by the assembly layer. See ADR-083.
module/crate — is open. `alknet-tls`'s stated job is "TLS setup, not - **OQ-61** (dissolved): Multi-owner shutdown coordination. The
transport accept logic," which is an argument against the helper problem does not arise — the endpoint owns all its accept loops
option. (quinn, iroh, TCP+TLS); `shutdown()` stops them all. See ADR-083.
- **OQ-61** (open): Multi-owner shutdown coordination. The endpoint owns
dispatched handlers (spawned in `dispatch`); the assembly layer owns
spawned accept loops (TCP+TLS). The coordination mechanism (shared
`shutdown_sender`, drain semantics) is unspecified.
## References ## References
@@ -392,8 +401,9 @@ See [open-questions.md](../../open-questions.md) for full details.
— client-side verifier selection (CA vs fingerprint pin) — client-side verifier selection (CA vs fingerprint pin)
- `docs/architecture/decisions/065-connection-from-stream-generic-single-stream.md` - `docs/architecture/decisions/065-connection-from-stream-generic-single-stream.md`
— `Connection::from_stream`/`from_bidi` (TCP+TLS path) — `Connection::from_stream`/`from_bidi` (TCP+TLS path)
- `docs/architecture/decisions/010-alpn-router-and-endpoint.md` Amendment 1 - `docs/architecture/decisions/010-alpn-router-and-endpoint.md`
— TCP+TLS dispatch via `from_stream` (accept loop outside the endpoint struct) Amendment 2 — TCP+TLS is a first-class owned transport
(`with_tcp_tls`); supersedes Amendment 1's sibling-loop framing
- `docs/architecture/crates/core/endpoint.md` — current endpoint design - `docs/architecture/crates/core/endpoint.md` — current endpoint design
(TLS section will be amended to point to `alknet-tls`) (TLS section will be amended to point to `alknet-tls`)
- `docs/architecture/crates/core/config.md` — `TlsIdentity`, `StaticConfig` - `docs/architecture/crates/core/config.md` — `TlsIdentity`, `StaticConfig`
@@ -254,4 +254,33 @@ related cleanup: it bumps the iroh dep to 1.0, unblocking `alknet-blobs`
6 API surface edits in `endpoint.rs` / `types.rs` (the `Endpoint::builder` 6 API surface edits in `endpoint.rs` / `types.rs` (the `Endpoint::builder`
preset, `SecretKey::from_bytes`/`generate` signatures, preset, `SecretKey::from_bytes`/`generate` signatures,
`Connection::remote_id`/`alpn` return types). No ADR needed; the endpoint `Connection::remote_id`/`alpn` return types). No ADR needed; the endpoint
design is unchanged. design is unchanged.
### Amendment 2 (2026-07-14): TCP+TLS is a first-class owned transport (supersedes Amendment 1's struct-level exclusion)
Amendment 1 preserved the "not an endpoint struct concern" framing at
the struct level — no `tcp: Option<TcpListener>` field on
`AlknetEndpoint`. The rationale was that the endpoint built transports
internally (quinn, iroh), and TCP+TLS couldn't fit that construction
shape, so it was a sibling loop outside the struct.
[ADR-083](083-endpoint-as-accept-loop-runner.md) removes that rationale:
the endpoint no longer builds transports at all — it runs accept loops on
whatever it's given via builder methods. TCP+TLS is a listener transport,
same shape as quinn and iroh (accept → extract ALPN + fingerprint →
`Connection::from_bidi` → `dispatch`). The endpoint now owns it via
`with_tcp_tls(listener, acceptor)` (behind a `tcp` feature), runs its
accept loop inside `run()`, and stops it on `shutdown()`. The struct gains
a `tcp_tls: Option<TcpTlsListener>` field.
Amendment 1's *dispatch* contribution survives — the public `dispatch`
method and `Connection::from_bidi` are what make TCP+TLS dispatch work.
Amendment 1's *struct-level exclusion* (no `tcp` field, sibling loop
outside) is **superseded**: TCP+TLS is now a first-class owned transport.
The `dispatch` method stays public, but for genuinely external shapes
(SSH channels, future WebTransport streams) — connection-internal
multiplexing, not listener transports.
This also means shutdown is single-owner: the endpoint owns all its
accept loops (quinn, iroh, TCP+TLS); one `shutdown()` stops them all.
The multi-owner shutdown coordination problem (OQ-61) does not arise.
@@ -174,11 +174,11 @@ transports.
`alknet-tls` provides `for_tcp_tls() -> TlsAcceptor`. The actual TCP `alknet-tls` provides `for_tcp_tls() -> TlsAcceptor`. The actual TCP
accept loop (`TcpListener::accept` → `TlsAcceptor::accept` → accept loop (`TcpListener::accept` → `TlsAcceptor::accept` →
`Connection::from_bidi` → `HandlerRegistry::dispatch`) lives elsewhere — `Connection::from_bidi` → `endpoint.dispatch()`) lives in `alknet-core`
in `alknet-hub` (the primary multi-transport consumer) or in a future behind a `tcp` feature, as an owned transport on `AlknetEndpoint` (via
`alknet-core` module. `alknet-tls` is the cert provider, not the `with_tcp_tls(listener, acceptor)` — see ADR-083). `alknet-tls` is the
accept loop. This keeps `alknet-tls` focused on TLS setup and cert cert provider, not the accept loop. This keeps `alknet-tls` focused on
sharing, not transport accept logic. TLS setup and cert sharing, not transport accept logic.
### One ACME state machine, shared ### One ACME state machine, shared
@@ -1,8 +1,12 @@
# ADR-083: Endpoint as Pure Accept-Loop Runner with Public Dispatch # ADR-083: Endpoint as Multi-Transport Accept-Loop Runner with Public Dispatch
## Status ## Status
Proposed Proposed (revised 2026-07-14: TCP+TLS moved inside the endpoint as a
third owned transport, not an external loop. This dissolves the
multi-owner shutdown problem — the endpoint owns all its accept loops.
`dispatch` stays public for genuinely external shapes: SSH channels,
WebTransport streams. See §"TCP+TLS is a first-class owned transport".)
## Context ## Context
@@ -50,29 +54,45 @@ transport-specific parts are narrow bookends:
Everything between those bookends — the ACME ALPN guard, the handler Everything between those bookends — the ACME ALPN guard, the handler
lookup, the `build_auth_context` call, the `tokio::spawn` — is identical. lookup, the `build_auth_context` call, the `tokio::spawn` — is identical.
Exposing a public `dispatch` method that takes the already-extracted
ALPN, fingerprint, and `Connection` lets every transport's accept loop
call the same dispatch path. No duplicated `build_auth_context` or
handler-lookup logic at the assembly layer.
### TCP+TLS is a first-class transport, not a sibling afterthought ### TCP+TLS is a first-class owned transport
TCP+TLS is the most common transport after raw-key QUIC, not an edge
case: HTTPS for browsers, worker registration over HTTP, raw-key
fallback for native clients when UDP is blocked. A hub — and any
hub-worker — needs it.
ADR-010 Amendment 1 made TCP+TLS a "sibling accept loop" outside the ADR-010 Amendment 1 made TCP+TLS a "sibling accept loop" outside the
endpoint because the endpoint's dispatch logic was crate-private and endpoint because the endpoint's dispatch logic was crate-private and
couldn't be shared. The assembly layer had to duplicate couldn't be shared. The reason TCP+TLS was *structurally* excluded —
`build_auth_context` and handler lookup, or call `HandlerRegistry::get` the endpoint built transports internally, and TCP+TLS couldn't fit that
directly. The "sibling" framing was a workaround for the endpoint being shape — is gone after this ADR: the endpoint no longer builds
welded to quinn — not a deliberate design. transports. It runs accept loops on whatever it's given.
With a public `dispatch` method, the TCP+TLS accept loop calls into the TCP+TLS is a listener transport, same shape as quinn and iroh:
endpoint — the same path quinn and iroh use. Amendment 1's *ownership*
model (the TCP+TLS listener owns its own `TcpListener` and `TlsAcceptor`,
living outside the endpoint struct) survives; its *dispatch* workaround
(duplicated `build_auth_context` and handler-lookup logic) is retired.
TCP+TLS is the most common transport after raw-key QUIC, not an edge | Transport | What `with_*` takes | Accept loop |
case: HTTPS for browsers, worker registration over HTTP, raw-key fallback |-----------|---------------------|-------------|
for native clients when UDP is blocked. | quinn | `quinn::Endpoint` | `quinn.accept()` → handshake → `Connection::from_quinn_with_alpn` |
| iroh | `iroh::Endpoint` | `iroh.accept()` → alpn+handshake → `Connection::from_iroh` |
| TCP+TLS | `TcpListener` + `TlsAcceptor` | `tcp.accept()` → `tls.accept()` → `Connection::from_bidi` |
All three are: accept → extract ALPN + fingerprint → construct
`Connection` → dispatch. The endpoint already runs the first two; the
third is the same pattern with a different accept call. Making TCP+TLS
an owned transport (via `with_tcp_tls(listener, acceptor)`) instead of
an external loop gives the endpoint a single, uniform ownership model:
it owns all its accept loops, `shutdown()` stops them all. No
coordination between the endpoint and external loops.
This dissolves the multi-owner shutdown problem (the endpoint owns all
loops; one owner, one shutdown). The `dispatch` method stays public —
but for genuinely external shapes that the endpoint can't own: SSH
channels (one connection, many channels with different ALPNs — a
multiplexing shape, not a listener shape) and future WebTransport
streams (one QUIC connection, many WT streams). These are not listener
transports; they're connection-internal multiplexing. The endpoint
can't own them the way it owns a `TcpListener`.
### The `acme-tls/1` guard ### The `acme-tls/1` guard
@@ -109,20 +129,25 @@ transports. `StaticConfig` becomes the canonical deployment config the
in `alknet-core` (it's a config type) and stays extensible (future in `alknet-core` (it's a config type) and stays extensible (future
transport config fields land here as the assembly layer needs them). transport config fields land here as the assembly layer needs them).
This is a conceptual shift from ADR-010's framing ("the CLI binary The "assembly layer" is, in practice, the deployment binary — today,
constructs a `HandlerRegistry` and passes it, with `StaticConfig`, to that's primarily the hub. A pure worker (no inbound endpoints) has a
`AlknetEndpoint::new()`"). The endpoint no longer takes `StaticConfig` trivial assembly layer; a hub-worker combines both. Putting
at all. The endpoint takes `drain_timeout` and pre-built transport hub-specific composition in the hub crate is appropriate; putting
endpoints; the assembly layer is the `StaticConfig` consumer. transport loops that any node might need in the hub crate is not. The
TCP+TLS loop belongs in `alknet-core` (behind a `tcp` feature), where
any node that wants it can enable the feature and call `with_tcp_tls` —
no hub dependency.
## Decision ## Decision
### The endpoint is a pure accept-loop runner + a public dispatch method ### The endpoint is a multi-transport accept-loop runner + a public dispatch method
```rust ```rust
pub struct AlknetEndpoint { pub struct AlknetEndpoint {
quinn: Option<quinn::Endpoint>, quinn: Option<quinn::Endpoint>,
iroh: Option<iroh::Endpoint>, iroh: Option<iroh::Endpoint>,
#[cfg(feature = "tcp")]
tcp_tls: Option<TcpTlsListener>, // (TcpListener, TlsAcceptor)
handlers: Arc<HandlerRegistry>, // ADR-010 handlers: Arc<HandlerRegistry>, // ADR-010
dynamic: Arc<ArcSwap<DynamicConfig>>, // ADR-010, unchanged dynamic: Arc<ArcSwap<DynamicConfig>>, // ADR-010, unchanged
identity_provider: Arc<dyn IdentityProvider>, // ADR-010, unchanged identity_provider: Arc<dyn IdentityProvider>, // ADR-010, unchanged
@@ -142,15 +167,29 @@ impl AlknetEndpoint {
pub fn with_quinn(mut self, endpoint: quinn::Endpoint) -> Self; pub fn with_quinn(mut self, endpoint: quinn::Endpoint) -> Self;
pub fn with_iroh(mut self, endpoint: iroh::Endpoint) -> Self; pub fn with_iroh(mut self, endpoint: iroh::Endpoint) -> Self;
/// Take ownership of a TCP+TLS listener. The endpoint runs the
/// accept loop (`tcp.accept()` → `tls.accept()` → extract ALPN +
/// fingerprint → `Connection::from_bidi` → `dispatch`). Feature-gated
/// on `tcp` (pulls `tokio-rustls`). A hub serving HTTPS and a
/// hub-worker serving TCP+TLS both use this; a pure worker doesn't.
#[cfg(feature = "tcp")]
pub fn with_tcp_tls(
mut self,
listener: tokio::net::TcpListener,
acceptor: tokio_rustls::TlsAcceptor,
) -> Self;
/// Clone of the shutdown watch sender. The assembly layer uses this /// Clone of the shutdown watch sender. The assembly layer uses this
/// to signal its own accept loops (TCP+TLS) to stop accepting — one /// to signal any external dispatch callers (SSH, future WT) to stop.
/// signal, all loops stop. See OQ-61 for the full coordination model. /// The endpoint's own loops (quinn, iroh, TCP+TLS) are signaled
/// internally by `shutdown()`.
pub fn shutdown_sender(&self) -> watch::Sender<bool>; pub fn shutdown_sender(&self) -> watch::Sender<bool>;
/// Dispatch a pre-established connection by ALPN. Called by the /// Dispatch a pre-established connection by ALPN. Called by the
/// endpoint's own accept loops (quinn, iroh) after transport- /// endpoint's own accept loops (quinn, iroh, TCP+TLS) after
/// specific extraction, and by external accept loops (TCP+TLS, /// transport-specific extraction, and by external dispatch callers
/// future SSH, future WebTransport) after their own extraction. /// (SSH channels, future WebTransport streams) after their own
/// extraction.
/// ///
/// Synchronous (non-async): performs the ACME guard, handler /// Synchronous (non-async): performs the ACME guard, handler
/// lookup, `build_auth_context`, and `tokio::spawn`s the handler. /// lookup, `build_auth_context`, and `tokio::spawn`s the handler.
@@ -170,52 +209,83 @@ impl AlknetEndpoint {
remote_addr: Option<SocketAddr>, remote_addr: Option<SocketAddr>,
); );
/// Run the endpoint's owned accept loops. Returns when shutdown is /// Run the endpoint's owned accept loops (quinn, iroh, TCP+TLS —
/// signaled. The caller drives shutdown via `shutdown_sender()`. /// whichever were configured). Returns when shutdown is signaled.
/// The caller drives shutdown via `shutdown()` or `shutdown_sender()`.
pub async fn run(self: Arc<Self>); pub async fn run(self: Arc<Self>);
/// Signal all owned accept loops to stop, drain in-flight handlers
/// for `drain_timeout`, then close. One owner, one shutdown — no
/// external loop coordination needed.
pub async fn shutdown(&self) -> Result<(), EndpointError>;
} }
``` ```
The consumer pattern: the assembly layer holds `Arc<AlknetEndpoint>`, The consumer pattern: the assembly layer (the deployment binary — today
clones the `Arc` for `run` (which consumes one clone), and passes primarily the hub) holds `Arc<AlknetEndpoint>`, clones the `Arc` for
`&endpoint` to its TCP+TLS accept loops so they can call `dispatch`. `run` (which consumes one clone), and — for external dispatch callers
After `run` returns, the assembly layer drops its `Arc`; in-flight like SSH — passes `&endpoint` to them so they can call `dispatch`. After
dispatched handlers (spawned by `dispatch` via `tokio::spawn`) are `run` returns (on shutdown), in-flight dispatched handlers drain per
owned by the endpoint's runtime and drain per `shutdown` / OQ-61. `shutdown()`.
`AlknetEndpoint::new` takes **no `StaticConfig`** and **no TLS config**. `AlknetEndpoint::new` takes **no `StaticConfig`** and **no TLS config**.
The assembly layer (the deployment binary that composes crates — see The assembly layer (the deployment binary that composes crates — see
ADR-014) reads `StaticConfig`, builds the transports, and hands the ADR-014) reads `StaticConfig`, builds the transports, and hands the
quinn/iroh endpoints to `AlknetEndpoint` via builder methods. The quinn/iroh/TCP+TLS endpoints to `AlknetEndpoint` via builder methods.
`handlers`, `dynamic`, and `identity_provider` fields are unchanged The `handlers`, `dynamic`, and `identity_provider` fields are unchanged
passthroughs from ADR-010 — this ADR does not change auth or dynamic passthroughs from ADR-010 — this ADR does not change auth or dynamic
config; it changes who builds transports and where dispatch lives. config; it changes who builds transports and where dispatch lives.
`build_iroh_endpoint` moves out of `alknet-core/endpoint.rs`. Its ### TCP+TLS is owned, not external
destination is an open question (see OQ-60) — the assembly layer, an
`alknet-tls` convenience helper, or a transport-construction
module/crate. This ADR commits to the **boundary** (construction is
not in the endpoint); the *where* is tracked separately so it doesn't
block the endpoint-shape decision. (`build_quinn_server_config_from_rustls`
is different: it's a thin wrapper that converts a `rustls::ServerConfig`
into a `quinn::ServerConfig`, which is exactly `TlsServerConfig::for_quinn()`
— its destination is `alknet-tls`, decided in ADR-082. Only
`build_iroh_endpoint`, which reads `StaticConfig` and builds an
`iroh::Endpoint` without a rustls config, is genuinely undecided.)
### `dispatch` is public and transport-agnostic The TCP+TLS accept loop runs inside `run()` alongside the quinn and
iroh loops. It is not an external sibling. This means:
- **Shutdown is single-owner.** `endpoint.shutdown()` stops all owned
loops (quinn, iroh, TCP+TLS) and drains all dispatched handlers. No
coordination between the endpoint and external accept loops. The
multi-owner shutdown problem (formerly OQ-61) does not arise.
- **Any node can use TCP+TLS.** A hub, a hub-worker, or any node that
accepts inbound TCP+TLS enables the `tcp` feature and calls
`with_tcp_tls(listener, acceptor)`. No hub dependency. The loop isn't
duplicated per binary.
- **The TCP+TLS loop's home is `alknet-core`** (behind a `tcp` feature),
not the hub crate or the assembly layer. Core already has `quinn` and
`iroh` as feature-gated transport deps; adding `tcp` (pulling
`tokio-rustls`) is the same pattern. A deployment that doesn't use
TCP+TLS doesn't enable the feature.
### `dispatch` is public — for genuinely external shapes
The public `dispatch` method takes the already-extracted ALPN, The public `dispatch` method takes the already-extracted ALPN,
fingerprint (if available), remote address (if available), and a fingerprint (if available), remote address (if available), and a
`Connection`. It performs the ACME guard, the handler lookup, the `Connection`. It performs the ACME guard, the handler lookup, the
`build_auth_context` call, and the `tokio::spawn`. `build_auth_context` `build_auth_context` call, and the `tokio::spawn`. `build_auth_context`
becomes a private helper called by `dispatch`, not a standalone becomes a private helper called by `dispatch`, not a standalone
function — the assembly layer calls `dispatch`, which calls function — external callers call `dispatch`, which calls
`build_auth_context` internally. `build_auth_context` internally.
`dispatch` is for transports the endpoint **can't own** — shapes that
aren't listener-based:
- **SSH channels** (ADR-065): one SSH connection carries multiple
channels with different ALPNs. The SSH handler accepts one connection,
then dispatches each channel via `dispatch` with the channel's
ALPN. This is connection-internal multiplexing, not a listener loop.
- **Future WebTransport streams** (parked per ADR-044): one QUIC
connection, many WT streams. Same multiplexing shape.
TCP+TLS is **not** an external dispatch caller — it's a listener
transport the endpoint owns. The distinction: listener transports
(quinn, iroh, TCP+TLS) produce connections from an accept loop the
endpoint runs; multiplexing transports (SSH, WT) produce connections
from within an existing connection, and the endpoint can't own their
accept loop.
Transport-specific extraction (`extract_quinn_alpn`, Transport-specific extraction (`extract_quinn_alpn`,
`extract_quinn_client_fingerprint`, `extract_iroh_client_fingerprint`) `extract_quinn_client_fingerprint`, `extract_iroh_client_fingerprint`,
stays private in the endpoint — the accept loops call them, then call `extract_tcp_tls_alpn`, `extract_tcp_tls_client_fingerprint`) stays
private in the endpoint — the accept loops call them, then call
`dispatch` with the results. `dispatch` with the results.
### The `acme-tls/1` guard moves to `dispatch` ### The `acme-tls/1` guard moves to `dispatch`
@@ -242,6 +312,22 @@ location changes. The `acme-tls/1` ALPN append still happens in
transport using the ACME `TlsServerConfig` advertises it, but in transport using the ACME `TlsServerConfig` advertises it, but in
practice only the TCP+TLS listener on 443 receives challenges. practice only the TCP+TLS listener on 443 receives challenges.
### Feature gates
```toml
[features]
quinn = ["dep:quinn"] # with_quinn — quinn accept loop
iroh = ["dep:iroh"] # with_iroh — iroh accept loop
tcp = ["dep:tokio-rustls"] # with_tcp_tls — TCP+TLS accept loop
acme = ["dep:rustls-acme"] # (used by alknet-tls, not core directly)
```
The `tcp` feature pulls `tokio-rustls`. A deployment enables the
features for the transports it runs. A pure-QUIC node enables `quinn` +
`iroh`; a hub serving HTTPS enables `quinn` + `tcp`; a hub-worker
enables all three. This matches the existing pattern (`quinn` and
`iroh` are already feature-gated transport deps on `alknet-core`).
### `StaticConfig` stays in core; the endpoint drops it ### `StaticConfig` stays in core; the endpoint drops it
`StaticConfig` remains in `alknet-core/config.rs` as the canonical `StaticConfig` remains in `alknet-core/config.rs` as the canonical
@@ -252,6 +338,33 @@ Future transport config fields (addresses, relay URLs, identity) land in
signature does not change when they're added, because the endpoint signature does not change when they're added, because the endpoint
doesn't read them. doesn't read them.
### Transport construction: inlined by the assembly layer
The builder functions are trivial API calls. The assembly layer (the
deployment binary — today primarily the hub crate's composition code)
inlines them:
- `build_quinn_endpoint`: `tls_config.for_quinn()` → `quinn::Endpoint::server(addr)` — 2 lines
- `build_iroh_endpoint`: iroh builder + key + relay + alpns + bind — 15 lines
- `build_tcp_tls`: `TcpListener::bind(addr)` + `tls_config.for_tcp_tls()` — 2 lines
These are pure configuration, not shared logic. No helper crate or
module — 20 lines total across all three, each binary picks which
transports it wants. If a future binary duplicates the iroh builder and
the pattern drifts, extraction is a two-way door on the function; the
*loop* (the runtime with shutdown + dispatch) is in core, so the
duplication risk is only on the trivial builder, not the real component.
Hub-specific composition (wiring `ChannelsAdapter`, `HttpAdapter`,
`CallAdapter`, the relay, peer lifecycle, worker registration) lives in
the hub crate. That's where multi-transport *composition* happens. The
endpoint provides the accept loops; the hub provides the handlers and
wiring. A `alknet-transport` crate was considered and rejected — it
would contain only trivial builder functions (the real component, the
TCP+TLS loop, is in core). A crate for 20 lines of API calls doesn't
earn its existence. If hub-specific transport helpers accumulate, they
live in the hub crate, not a generic transport crate.
### What moves out of `alknet-core/endpoint.rs` ### What moves out of `alknet-core/endpoint.rs`
| Code | Destination | | Code | Destination |
@@ -264,19 +377,18 @@ doesn't read them.
| `SelfSignedCert` / `generate_self_signed_cert()` | `alknet-tls` (ADR-082) | | `SelfSignedCert` / `generate_self_signed_cert()` | `alknet-tls` (ADR-082) |
| `load_cert_chain()` / `load_private_key()` | `alknet-tls` (ADR-082) | | `load_cert_chain()` / `load_private_key()` | `alknet-tls` (ADR-082) |
| `build_quinn_server_config_from_rustls()` | `alknet-tls` (`for_quinn()`, ADR-082) | | `build_quinn_server_config_from_rustls()` | `alknet-tls` (`for_quinn()`, ADR-082) |
| `build_iroh_endpoint()` | Out of core (destination: OQ-60) | | `build_iroh_endpoint()` | Assembly layer (inlined; 15 lines of iroh API calls) |
### What stays in `alknet-core/endpoint.rs` ### What stays in `alknet-core/endpoint.rs`
- `AlknetEndpoint` struct (accept-loop runner + public `dispatch`) - `AlknetEndpoint` struct (multi-transport accept-loop runner + public `dispatch`)
- `HandlerRegistry` - `HandlerRegistry`
- `dispatch` (public — ACME guard, handler lookup, `build_auth_context`, - `dispatch` (public — ACME guard, handler lookup, `build_auth_context`, spawn)
spawn) - `dispatch_quinn` / `dispatch_iroh` / `dispatch_tcp_tls` (private — transport-specific extraction, then call `dispatch`)
- `dispatch_quinn` / `dispatch_iroh` (private — transport-specific - `run_quinn_accept_loop` / `run_iroh_accept_loop` / `run_tcp_tls_accept_loop`
extraction, then call `dispatch`)
- `run_quinn_accept_loop` / `run_iroh_accept_loop`
- `extract_quinn_alpn` / `extract_quinn_client_fingerprint` / - `extract_quinn_alpn` / `extract_quinn_client_fingerprint` /
`extract_iroh_client_fingerprint` `extract_iroh_client_fingerprint` / `extract_tcp_tls_alpn` /
`extract_tcp_tls_client_fingerprint`
- `build_auth_context` (private helper, called by `dispatch`) - `build_auth_context` (private helper, called by `dispatch`)
### ADR-082 amendment ### ADR-082 amendment
@@ -284,19 +396,26 @@ doesn't read them.
ADR-082's `AlknetEndpoint::new(..., Arc<TlsServerConfig>, ...)` signature ADR-082's `AlknetEndpoint::new(..., Arc<TlsServerConfig>, ...)` signature
is superseded by this ADR. The endpoint takes no TLS config; the is superseded by this ADR. The endpoint takes no TLS config; the
assembly layer builds transports from `TlsServerConfig`s and hands them assembly layer builds transports from `TlsServerConfig`s and hands them
to the endpoint via `with_quinn` / `with_iroh`. ADR-082 should reference to the endpoint via `with_quinn` / `with_iroh` / `with_tcp_tls`. ADR-082
this ADR for the endpoint signature and focus on what `alknet-tls` should reference this ADR for the endpoint signature and focus on what
provides (`TlsServerConfig` and its accessors). `alknet-tls` provides (`TlsServerConfig` and its accessors).
## Consequences ## Consequences
**Positive:** **Positive:**
- The endpoint has one job: dispatch. Transport construction lives - The endpoint has one job: run accept loops + dispatch. Transport
outside it, where the multi-`TlsServerConfig` hub case is natural. construction lives outside it, where the multi-`TlsServerConfig` hub
- TCP+TLS dispatch is first-class — same `dispatch` path as quinn/iroh, case is natural.
no duplicated `build_auth_context` or handler-lookup logic at the - TCP+TLS is a first-class owned transport — same `run()` loop, same
assembly layer. ADR-010 Amendment 1's second-class-dispatch workaround `shutdown()`, same dispatch path as quinn/iroh. No duplicated
is retired. `build_auth_context` or handler-lookup logic anywhere. ADR-010
Amendment 1's sibling-dispatch workaround is retired entirely.
- Shutdown is single-owner. The endpoint owns all its loops; one
`shutdown()` stops them all and drains. The multi-owner shutdown
problem (formerly OQ-61) does not arise.
- Any node can use TCP+TLS — enable the `tcp` feature, call
`with_tcp_tls`. No hub dependency. A hub-worker serving TCP+TLS is
the same code path as a hub serving TCP+TLS.
- The `acme-tls/1` guard is transport-agnostic — it works for TCP+TLS - The `acme-tls/1` guard is transport-agnostic — it works for TCP+TLS
(where challenges actually arrive) and any future transport. ADR-027 (where challenges actually arrive) and any future transport. ADR-027
§5's quinn-specific location is corrected. §5's quinn-specific location is corrected.
@@ -304,51 +423,61 @@ provides (`TlsServerConfig` and its accessors).
the endpoint's. Adding transport config fields doesn't churn the the endpoint's. Adding transport config fields doesn't churn the
endpoint's `new` signature. endpoint's `new` signature.
- The hub (the first multi-transport consumer) is unblocked: build two - The hub (the first multi-transport consumer) is unblocked: build two
`TlsServerConfig`s, build quinn/iroh/TCP+TLS listeners, hand the `TlsServerConfig`s, build quinn/iroh/TCP+TLS listeners, hand all
endpoint the quinn/iroh ones, spawn the TCP+TLS loops calling three to the endpoint, `run()`.
`endpoint.dispatch`. - `dispatch` stays public for genuinely external shapes (SSH channels,
future WT streams) — transports the endpoint can't own because they're
connection-internal multiplexing, not listener-based.
**Negative:** **Negative:**
- `AlknetEndpoint::new` signature changes (breaking). Pre-1.0, in-repo - `AlknetEndpoint::new` signature changes (breaking). Pre-1.0, in-repo
consumers only — the assembly layer and tests must update. Expected; consumers only — the assembly layer and tests must update. Expected;
this is the point of the refactor. this is the point of the refactor.
- `build_iroh_endpoint` leaves core. Its destination is an open question - `alknet-core` gains a `tcp` feature (pulls `tokio-rustls`). This is
(OQ-60), not decided here. The boundary (not in the endpoint) is the same pattern as the existing `quinn` and `iroh` features — a
committed; the *where* is not, so that the endpoint-shape decision transport that the endpoint can own. A deployment that doesn't use
isn't blocked on the transport-construction-location decision. TCP+TLS doesn't enable it. `alknet-core` already depends on `rustls`
(`build_quinn_server_config_from_rustls` is decided — it moves to (for `fingerprint.rs` types, per OQ-59); `tokio-rustls` is the
`alknet-tls` as `for_quinn()` per ADR-082; only `build_iroh_endpoint` acceptor wrapper over `rustls::ServerConfig`, not a separate TLS
is open.) stack.
- Multi-owner shutdown: the endpoint owns shutdown of *dispatched - `build_iroh_endpoint` leaves core (inlined by the assembly layer).
handlers* (it spawned them in `dispatch`); the assembly layer owns This is a one-way dep-graph change: the binary that uses iroh depends
shutdown of the *accept loops it spawned* (TCP+TLS listeners). The on `iroh` directly, not via `alknet-core`. This is correct — the
coordination mechanism (shared `shutdown_sender`, drain semantics) is binary *is* the thing that knows which transports it wants. The 15
a follow-up design point, not resolved by this ADR — see OQ-61. lines are pure iroh API calls; no shared logic is lost.
- This ADR revises ADR-010's "TCP is not an endpoint struct concern"
more deeply than the original ADR-083 draft. The reason TCP was
excluded (the endpoint built transports internally, TCP+TLS couldn't
fit) is gone; the endpoint is now a multi-transport accept-loop runner
and TCP+TLS is a listener transport that fits the same shape.
## Door type ## Door type
**One-way.** The endpoint's `new` signature, the public `dispatch` **One-way.** The endpoint's `new` signature, the public `dispatch`
contract, and the "endpoint owns dispatch, not construction" boundary contract, the "endpoint owns dispatch, not construction" boundary, and
are structural. Reversing would mean re-welding transport construction the "endpoint owns TCP+TLS as a first-class transport" decision are
to the endpoint and re-privatizing `dispatch` — breaking every structural. Reversing would mean re-welding transport construction to
multi-transport consumer (hub, future HTTP, future SSH). the endpoint, re-privatizing `dispatch`, and pushing TCP+TLS back outside
— breaking every multi-transport consumer (hub, hub-worker, future SSH,
future WT).
The `dispatch` signature (`connection, alpn, fingerprint, remote_addr`) The `dispatch` signature (`connection, alpn, fingerprint, remote_addr`)
is one-way — changing it after consumers exist is a rewrite. The is one-way — changing it after consumers exist is a rewrite. The
internal implementation (how extraction is factored, how `run` spawns internal implementation (how extraction is factored, how `run` spawns
tasks) is two-way. tasks, the TCP+TLS loop's internal structure) is two-way.
## References ## References
- ADR-010: ALPN router and endpoint (amended — the endpoint no longer - ADR-010: ALPN router and endpoint (amended — the endpoint no longer
constructs transports; Amendment 1's second-class-dispatch workaround constructs transports; "TCP is not an endpoint struct concern" is
is retired by the public `dispatch` method) revised: TCP+TLS is now a first-class owned transport via
`with_tcp_tls`; Amendment 1's sibling-dispatch workaround is retired
by the public `dispatch` method and the owned TCP+TLS loop)
- ADR-014: Secret material flow and capability injection (defines the - ADR-014: Secret material flow and capability injection (defines the
"assembly layer" term — the deployment binary that composes crates) "assembly layer" term — the deployment binary that composes crates)
- ADR-010 Amendment 1: TCP+TLS as sibling (superseded — Amendment 1's - ADR-010 Amendment 1: TCP+TLS as sibling (superseded — TCP+TLS is now
*ownership* model (sibling listener outside the endpoint) survives; an owned transport, not a sibling; `dispatch` is public for SSH/WT,
its *dispatch* workaround (duplicated logic) is retired by this ADR's not for TCP+TLS)
public `dispatch`)
- ADR-027 §5: ACME ALPN challenge handling (location amended — guard - ADR-027 §5: ACME ALPN challenge handling (location amended — guard
moves from `dispatch_quinn` to shared `dispatch`) moves from `dispatch_quinn` to shared `dispatch`)
- ADR-065: `Connection::from_stream`/`from_bidi` (the primitive that - ADR-065: `Connection::from_stream`/`from_bidi` (the primitive that
@@ -362,7 +491,8 @@ tasks) is two-way.
- `docs/research/alknet-endpoint-refactor/findings.md` — the analysis - `docs/research/alknet-endpoint-refactor/findings.md` — the analysis
that surfaced the conflation and the two-config hub case that surfaced the conflation and the two-config hub case
- `crates/alknet-core/src/endpoint.rs` — the code being refactored - `crates/alknet-core/src/endpoint.rs` — the code being refactored
- OQ-60: Where does transport construction live? (assembly layer, - OQ-60: resolved — transport construction is inlined by the assembly
`alknet-tls` helper, or transport module/crate) layer (the deployment binary); the TCP+TLS loop lives in
- OQ-61: Multi-owner shutdown coordination (endpoint owns dispatched `alknet-core` behind a `tcp` feature as an owned transport
handlers; assembly layer owns spawned accept loops) - OQ-61: dissolved — the multi-owner shutdown problem does not arise;
the endpoint owns all its accept loops (quinn, iroh, TCP+TLS)
+2 -2
View File
@@ -77,8 +77,8 @@ Door type is separate from whether a decision is made. A two-way door is a decis
| [OQ-14](questions/014-batch-operation-semantics.md) | Batch Operation Semantics | resolved | two | low | | [OQ-14](questions/014-batch-operation-semantics.md) | Batch Operation Semantics | resolved | two | low |
| [OQ-55](questions/055-alknetclient-establishment-extraction.md) | AlknetClient / Client Establishment Extraction | deferred(scope) | two | med | | [OQ-55](questions/055-alknetclient-establishment-extraction.md) | AlknetClient / Client Establishment Extraction | deferred(scope) | two | med |
| [OQ-59](questions/059-fingerprint-module-location.md) | Should `fingerprint.rs` Stay in Core or Move to `alknet-tls`? | open | two | med | | [OQ-59](questions/059-fingerprint-module-location.md) | Should `fingerprint.rs` Stay in Core or Move to `alknet-tls`? | open | two | med |
| [OQ-60](questions/060-transport-construction-location.md) | Where Does Transport Construction Live? | open | one | high | | [OQ-60](questions/060-transport-construction-location.md) | Where Does Transport Construction Live? | resolved | one | high |
| [OQ-61](questions/061-multi-owner-shutdown-coordination.md) | Multi-Owner Shutdown Coordination | open | two | med | | [OQ-61](questions/061-multi-owner-shutdown-coordination.md) | Multi-Owner Shutdown Coordination | dissolved | two | med |
### alknet-call ### alknet-call
@@ -2,49 +2,69 @@
- **Origin**: `docs/architecture/decisions/083-endpoint-as-accept-loop-runner.md` - **Origin**: `docs/architecture/decisions/083-endpoint-as-accept-loop-runner.md`
(the endpoint refactor commits to the boundary — construction is not (the endpoint refactor commits to the boundary — construction is not
in the endpoint — but not the location of `build_iroh_endpoint`). in the endpoint — but not the location of transport construction).
- **Status**: open - **Status**: resolved
- **Door type**: one-way (where `build_iroh_endpoint` lives determines - **Door type**: one-way (where `build_iroh_endpoint` and the TCP+TLS
who depends on `iroh` for transport construction; moving it later loop live determines who depends on `iroh` / `tokio-rustls` for
churns the dep graph and every binary's assembly code) transport construction; the dep-graph shape is structural)
- **Priority**: high (the hub is the first multi-transport consumer; - **Priority**: high (the hub is the first multi-transport consumer;
its assembly code sets the pattern) its assembly code sets the pattern)
- **Blocked on**: nothing structural. The three options are clear; the - **Resolution**: Split answer (resolved by ADR-083 revision, 2026-07-14):
decision is a trade-off, not a missing capability.
- **Scope**: This OQ covers **`build_iroh_endpoint`** — the function
that reads `StaticConfig` and builds an `iroh::Endpoint`.
`build_quinn_server_config_from_rustls` is **decided** (it moves to
`alknet-tls` as `TlsServerConfig::for_quinn()` per ADR-082 — it's a
thin wrapper over a `rustls::ServerConfig`, which `alknet-tls` owns).
Only `build_iroh_endpoint` is genuinely undecided: it reads
`StaticConfig` (not a rustls config), builds an `iroh::Endpoint` from
an `Ed25519SecretKey` + relay URL, and doesn't fit the `alknet-tls`
cert-provider boundary.
- **Resolution**: Not yet decided. The options:
**Option A: In the assembly layer (the binary).** Each binary reads **TCP+TLS accept loop → `alknet-core` behind a `tcp` feature (owned
`StaticConfig` and hand-assembles quinn/iroh/TCP+TLS. Pro: maximal by the endpoint).** The endpoint takes a `TcpListener` +
flexibility, core stays lean. Con: every binary duplicates the `TlsAcceptor` via `with_tcp_tls(listener, acceptor)` and runs the
"build an iroh endpoint from an `Ed25519SecretKey` + relay URL" accept loop inside `run()` alongside the quinn and iroh loops. TCP+TLS
boilerplate; a 10-step procedure is copy-pasted per binary. is a listener transport — same shape as quinn and iroh (accept →
extract ALPN + fingerprint → `Connection::from_bidi` → `dispatch`).
Making it owned gives the endpoint a single uniform ownership model:
it owns all its accept loops, `shutdown()` stops them all. The
multi-owner shutdown problem (OQ-61) does not arise. Any node that
wants TCP+TLS enables the `tcp` feature and calls `with_tcp_tls` — no
hub dependency. This reverses ADR-010's "TCP is not an endpoint struct
concern": the reason TCP was excluded (the endpoint built transports
internally, TCP+TLS couldn't fit) is gone; the endpoint is now a
multi-transport accept-loop runner and TCP+TLS fits the same shape.
**Option B: In `alknet-tls` as convenience helpers.** `alknet-tls` **Builder functions (`build_iroh_endpoint`, `build_quinn_endpoint`,
gains a `build_iroh_endpoint` helper. Pro: one place. Con: `alknet-tls` `build_tcp_tls`) → inlined by the assembly layer.** These are trivial
then depends on `iroh`, bloats a crate whose stated job is "TLS setup, API calls (2-15 lines each, pure configuration, no shared logic). The
not transport endpoint construction" (ADR-082 scopes `alknet-tls` to assembly layer (the deployment binary — today primarily the hub
cert config and its accessors — `build_iroh_endpoint` doesn't touch crate's composition code) inlines them. No helper crate or module —
certs, it touches iroh's relay/secret-key APIs). 20 lines total across all three. A `alknet-transport` crate was
considered and rejected: it would contain only trivial builders (the
real component, the TCP+TLS loop, is in core). Hub-specific transport
helpers, if they accumulate, live in the hub crate, not a generic
transport crate.
**Option C: A new crate or module — `alknet-transport` / an **`dispatch` stays public** — but for genuinely external shapes (SSH
`alknet-core::transport` module — that owns transport construction.** channels, future WebTransport streams), not for TCP+TLS. The
Pro: clean separation (TLS = certs, transport = endpoints, endpoint = distinction: listener transports (quinn, iroh, TCP+TLS) produce
dispatch). Con: another layer in the dep graph. connections from an accept loop the endpoint owns; multiplexing
transports (SSH, WT) produce connections from within an existing
connection, and the endpoint can't own their accept loop.
The ADR-082 boundary ("`alknet-tls` is the cert provider, not the **Why not the hub crate for the TCP+TLS loop:** "the assembly layer"
transport constructor") is a strong argument against Option B. Option C is, in practice, usually the hub. But a hub-worker serving TCP+TLS
is the cleanest separation but adds a layer. Option A is simplest but shouldn't depend on the hub crate for a transport loop. The loop
risks per-binary duplication that drifts. belongs in core (behind a feature), where any node can use it. The hub
- **Cross-references**: ADR-083 (endpoint refactor), ADR-082 crate owns hub-specific *composition* (wiring adapters, relay, peer
(`alknet-tls` — the cert provider boundary; `for_quinn()` is in scope, lifecycle), not transport runtimes.
`build_iroh_endpoint` is not), ADR-010 (original endpoint design,
where construction was welded to the endpoint) **Why not `alknet-tls`:** `alknet-tls`'s stated job is TLS setup —
`rustls::ServerConfig`, cert resolvers, ACME (ADR-082). The TCP+TLS
accept loop is transport runtime, not TLS setup. It calls
`endpoint.dispatch()`, which is core's API. Adding it to `alknet-tls`
would make a cert-provider crate depend on the endpoint's dispatch API
and own a transport runtime — a category error.
**Why not `alknet-transport` (new crate):** The real component (the
TCP+TLS loop) is in core. What's left for a transport crate is trivial
builder functions. A crate for 20 lines of API calls doesn't earn its
existence. If future transport runtimes accumulate that don't fit
core, a transport crate can be created then — but not speculatively.
- **Cross-references**: ADR-083 (endpoint refactor — the revision that
resolved this), ADR-082 (`alknet-tls` — the cert provider boundary),
ADR-010 (original endpoint design — "TCP is not an endpoint struct
concern" is revised), OQ-61 (dissolved — the multi-owner shutdown
problem does not arise with TCP+TLS owned by the endpoint)
@@ -2,41 +2,28 @@
- **Origin**: `docs/architecture/decisions/083-endpoint-as-accept-loop-runner.md` - **Origin**: `docs/architecture/decisions/083-endpoint-as-accept-loop-runner.md`
(the endpoint owns dispatched handlers; the assembly layer owns (the endpoint owns dispatched handlers; the assembly layer owns
spawned accept loops — coordination between them on shutdown is spawned accept loops — coordination between them on shutdown was
unspecified). unspecified).
- **Status**: open - **Status**: dissolved
- **Door type**: two-way (the coordination mechanism is an - **Door type**: two-way (the coordination mechanism was an
implementation detail; the ownership boundary — endpoint owns implementation detail)
dispatched handlers, assembly layer owns spawned accept loops — is - **Priority**: medium
the one-way part, committed in ADR-083) - **Resolution**: Dissolved by ADR-083 revision (2026-07-14). The
- **Priority**: medium (matters for clean hub shutdown; doesn't block problem this OQ tracked — coordinating shutdown between the endpoint's
the endpoint-shape decision or the TLS extraction) owned loops and external TCP+TLS accept loops — does not arise. The
- **Blocked on**: nothing structural. The question is which TCP+TLS accept loop is now an owned transport (via `with_tcp_tls`),
coordination primitive (shared `shutdown_sender`, separate channel, not an external sibling. The endpoint owns all its accept loops
drain semantics) — a design choice, not a missing capability. (quinn, iroh, TCP+TLS); `shutdown()` stops them all and drains all
- **Resolution**: Not yet decided. The boundary is clear: dispatched handlers. One owner, one shutdown. The `dispatch` method
stays public for SSH channels and future WebTransport streams, but
those are connection-internal multiplexing callers, not listener
loops with independent shutdown needs — they call `dispatch` on
connections the endpoint already accepted and dispatched a handler
for.
- The endpoint owns shutdown of **dispatched handlers** (it spawned The premise of this OQ (external TCP+TLS loop + endpoint-owned
them via `tokio::spawn` in `dispatch`). dispatch = multi-owner shutdown) was a consequence of TCP+TLS being
- The assembly layer owns shutdown of the **accept loops it spawned** external. ADR-083's revision made TCP+TLS internal, retiring the
(the TCP+TLS listeners). premise.
- **Cross-references**: ADR-083 (endpoint refactor — TCP+TLS is now
The open question is the **coordination mechanism**: owned), OQ-60 (resolved — the TCP+TLS loop lives in core)
- Does the assembly layer use the endpoint's `shutdown_sender()` to
signal the TCP+TLS loops, or a separate channel?
- Does `endpoint.shutdown()` drain in-flight dispatched handlers
(including those from TCP+TLS loops), or only the quinn/iroh ones
it spawned via `run()`?
- What happens to in-flight `dispatch` calls after the endpoint is
shut down — are they rejected, or do they complete?
A likely shape: the assembly layer uses the endpoint's
`shutdown_sender()` for all its accept loops (one signal, all loops
stop accepting); the endpoint's `shutdown()` drains all dispatched
handlers regardless of which transport spawned them (the endpoint
owns them all once `dispatch` is called). But this needs to be
written down and verified against the drain semantics.
- **Cross-references**: ADR-083 (endpoint refactor — ownership
boundary), ADR-010 (original shutdown design — single-owner, the
simpler case being generalized)