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

+66 -36
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-09
last_updated: 2026-07-14
---
# Endpoint
@@ -15,17 +15,26 @@ The central runtime type. Manages one or more QUIC connection sources, each feed
```rust
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
iroh: Option<iroh::Endpoint>, // P2P relay-assisted
#[cfg(feature = "tcp")]
tcp_tls: Option<TcpTlsListener>, // TCP+TLS (TcpListener + TlsAcceptor)
handlers: Arc<HandlerRegistry>,
dynamic: Arc<ArcSwap<DynamicConfig>>,
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?
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.
### 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
owned by the `AlknetEndpoint` struct — there is no `tcp:
Option<TcpListener>` field. The endpoint manages QUIC connection sources
(quinn + iroh) only.
TCP+TLS is a listener transport, same shape as quinn and iroh. The
endpoint owns it via `with_tcp_tls(listener, acceptor)` (behind a `tcp`
feature) and runs its accept loop inside `run()` — `tcp.accept()` →
`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
[ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md),
`Connection::from_bidi(tls_stream, alpn, remote_addr)` constructs a
`Connection` from any `TlsStream<TcpStream>` (or any `AsyncRead +
AsyncWrite` pair). A TCP+TLS accept loop built *outside* the endpoint (by
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.
This reverses ADR-010's original "TCP is not an endpoint struct concern."
The reason TCP was excluded — the endpoint built transports internally,
and TCP+TLS couldn't fit that shape — is gone (ADR-083: the endpoint no
longer builds transports; it runs accept loops on whatever it's given).
TCP+TLS fits the same listener shape as quinn and iroh.
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
@@ -136,24 +144,39 @@ See iroh's `protocol.rs` (`/workspace/iroh/iroh/src/protocol.rs`) for the refere
### 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) {
let alpn = connection.alpn();
match handlers.get(alpn) {
pub fn dispatch(&self, connection: Connection, alpn: Vec<u8>,
fingerprint: Option<String>, remote_addr: Option<SocketAddr>) {
// ACME guard (transport-agnostic — ADR-083)
if alpn == b"acme-tls/1" {
connection.close(0, "acme done");
return;
}
match handlers.get(&alpn) {
Some(handler) => {
let auth = AuthContext::from_connection(&connection);
let conn = Connection::from_quinn(connection); // or from_iroh
let auth = build_auth_context(&alpn, remote_addr, fingerprint, &identity_provider);
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
}
});
}
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
- **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()` signals all accept loops to stop, waits for in-flight connections with a drain timeout (default: 2 seconds), then forcefully closes remaining connections.
- `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 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.
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
@@ -308,9 +334,11 @@ Non-fatal errors within a handler. See [core-types.md](core-types.md) for detail
| 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 |
| 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 |
| 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 |
@@ -322,4 +350,6 @@ See [open-questions.md](../../open-questions.md) for full details.
- **OQ-04**: Resolved — HandlerRegistry is static at startup.
- **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
last_updated: 2026-07-13
last_updated: 2026-07-14
---
# alknet-hub
@@ -42,11 +42,11 @@ welded to a dial. See "Transport" below.
### What the hub provides
1. **Multi-transport endpoint** — accepts channels connections over
QUIC (via the quinn `AlknetEndpoint`) and over TCP+TLS (via an
accept loop that wraps each `TlsStream<TcpStream>` as a `Connection`
with `from_bidi`, per ADR-065/010). Both feed the same
`HandlerRegistry`. Also serves HTTP (`h2`/`http/1.1`) over TCP+TLS
for registration and browser access. All three coexist.
QUIC and over TCP+TLS (both owned transports on `AlknetEndpoint` via
`with_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 and `shutdown()` stops them all.
2. **Peer lifecycle** — accept, dial, disconnect, reconnect with
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**
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
channel 0 on each leg, translating `channel/open`, byte-forwarding
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 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 |
|----------|-----------|-----------------|
| Quinn `AlknetEndpoint` | QUIC (quinn/iroh) | Channels connections from workers with QUIC reachability |
| TCP+TLS accept loop | TCP + TLS (`h2`/`http/1.1`/`alknet/channels`) | HTTP registration endpoint, browser access, channels-over-TCP from workers |
| 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 feed into the same `HandlerRegistry`. The hub's
`HttpAdapter` serves `h2`/`http/1.1` over the TCP+TLS path for
registration and browser access; the `ChannelsAdapter` (registered on
the hub's `HandlerRegistry`) serves
`alknet/channels` over the QUIC path (and over TCP+TLS when a worker
dials channels-over-TCP). Both ALPNs are registered on the same
registry; the TLS handshake on each connection negotiates the ALPN
and dispatches to the right adapter.
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 `HandlerRegistry::dispatch` the quinn endpoint
uses. This is the accept-loop-outside-the-endpoint pattern from
ADR-010's Amendment 1: the `AlknetEndpoint` struct stays QUIC-only
(no `tcp` field), and the TCP+TLS loop is a sibling accept source
that shares the registry. The hub is where that sibling loop lives.
`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
@@ -330,8 +337,8 @@ let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::ne
let channels_adapter = ChannelsAdapter::new(Arc::clone(&registry), /* ... */)
.with_worker_connected_callback(callback);
// Register channels_adapter on alknet/channels in the HandlerRegistry.
// Both the quinn endpoint and the TCP+TLS accept loop dispatch
// alknet/channels connections to it.
// 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
@@ -596,37 +603,38 @@ let hub = Arc::new(Hub::new(
Arc::clone(&identity_provider),
).with_ownership_provider(ownership_provider));
// 3. Register the ChannelsAdapter on alknet/channels. Both the quinn
// endpoint and the TCP+TLS accept loop dispatch alknet/channels
// connections to this adapter.
// 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 TCP+TLS accept loop
// dispatches h2/http1.1 connections to this adapter (registration
// endpoint, browser access, stealth decoy).
// 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. Start the QUIC accept loop (workers with QUIC reachability)
let quinn_endpoint = AlknetEndpoint::new(/* ... */, Arc::clone(&registry));
quinn_endpoint.run().await;
// 5. Build the quinn endpoint (raw-key config for native clients)
let quinn_endpoint = raw_key_tls.for_quinn()?.into_endpoint(listen_addr)?;
// 6. Start the TCP+TLS accept loop (registration, browser access,
// channels-over-TCP from workers)
// 6. Build the TCP+TLS listener (X.509/ACME config for HTTPS)
let tcp_listener = TcpListener::bind(registration_addr).await?;
tokio::spawn(async move {
loop {
let (stream, _) = tcp_listener.accept().await?;
let tls_stream = tls_acceptor.accept(stream).await?;
let alpn = tls_stream.alpn()?;
let conn = Connection::from_bidi(tls_stream, alpn, /* remote_addr */);
registry.dispatch(conn).await; // routes by ALPN to HttpAdapter or ChannelsAdapter
}
});
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
@@ -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 |
| ChannelClient — transport-agnostic | [ADR-080](../../decisions/080-channelclient.md) | `from_connection` primary, `connect_quic` convenience; the dial path the hub uses |
| Channels transport-agnostic | [ADR-071](../../decisions/071-channels-wire-format.md) | Substrate modes; `Connection::from_stream`/`from_bidi` (ADR-065) — the substrate the hub relays |
| TCP+TLS dispatch via from_stream | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) Am. 1 | `AlknetEndpoint` stays QUIC-only; TCP+TLS accept loop shares the registry |
| 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 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-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](/workspace/@alkdev/alkapi/docs/architecture/hub.md) —
the first hub consumer, the concrete use case that informed this
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
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
runner with a public `dispatch` method:
the endpoint takes **no TLS config at all** — it is a multi-transport
accept-loop runner. TCP+TLS is an owned transport (via `with_tcp_tls`),
not an external loop:
```rust
impl AlknetEndpoint {
@@ -283,6 +284,17 @@ impl AlknetEndpoint {
pub fn with_quinn(mut self, endpoint: quinn::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(
&self,
connection: Connection,
@@ -292,32 +304,35 @@ impl AlknetEndpoint {
);
pub async fn run(self: Arc<Self>);
pub async fn shutdown(&self) -> Result<(), EndpointError>;
}
```
The assembly layer builds the `TlsServerConfig`(s), builds the
transports (`for_quinn()` → `quinn::Endpoint::server()`,
`for_tcp_tls()` → `TlsAcceptor`, the `Ed25519SecretKey` → iroh), and
hands the pre-built quinn/iroh endpoints to `AlknetEndpoint` via
`for_tcp_tls()` → `TlsAcceptor` paired with a `TcpListener`,
`Ed25519SecretKey` → iroh), and hands them to `AlknetEndpoint` via
builder methods. A hub serving native clients and browsers holds two
`TlsServerConfig`s (raw key + X.509/ACME); the endpoint takes neither —
it takes the already-built transport endpoints. The TCP+TLS accept
loops (one per config) call `endpoint.dispatch(...)` — the same
dispatch path quinn and iroh use. The ACME handle lives on the
`TlsServerConfig`, not the endpoint.
it takes the already-built transport endpoints. The TCP+TLS listener
is owned by the endpoint via `with_tcp_tls`; the endpoint runs its
accept loop inside `run()` and stops it on `shutdown()`. The ACME
handle lives on the `TlsServerConfig`, not 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)
`alknet-tls` provides `for_tcp_tls() -> TlsAcceptor`. The actual TCP
accept loop (`TcpListener::accept` → `TlsAcceptor::accept` →
`Connection::from_bidi` → `HandlerRegistry::dispatch`) lives elsewhere —
in `alknet-hub` (the hub is the primary multi-transport consumer) or in
a future `alknet-core` `TcpTlsAcceptor` module. `alknet-tls` is the cert
provider, not the accept loop. This keeps `alknet-tls` focused on TLS
setup and cert sharing, not transport accept logic.
`Connection::from_bidi` → `endpoint.dispatch()`) lives in `alknet-core`
behind a `tcp` feature, as an owned transport on `AlknetEndpoint` (via
`with_tcp_tls(listener, acceptor)` — see ADR-083). `alknet-tls` is the
cert provider, not the accept loop. This keeps `alknet-tls` focused on
TLS setup and cert sharing, not transport accept logic.
## Crate dependencies (in the dep graph)
@@ -331,23 +346,21 @@ alknet-core (loses TLS setup code)
alknet-call (client-side verifier — unchanged)
├── 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-tls (TlsServerConfig — shared across quinn + TCP)
├── alknet-channels-call (ChannelClient)
├── alknet-call (CallAdapter, Dispatcher)
├── 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` — they depend on `alknet-core` for types and on
`alknet-tls` only indirectly through the assembly layer. The assembly
layer (the deployment binary) builds the `TlsServerConfig` and passes it
to the endpoint and the TCP+TLS accept loop.
layer (the deployment binary) builds the `TlsServerConfig`(s), builds
the transport endpoints (quinn/iroh/TCP+TLS), and hands them to
`AlknetEndpoint` via `with_quinn` / `with_iroh` / `with_tcp_tls`
(ADR-083).
## Design Decisions
@@ -357,7 +370,7 @@ All design decisions are documented as ADRs in
| 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 |
| [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
@@ -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
`rustls` dep. Decision-ready — the answer depends on whether we want
core to be `rustls`-free.
- **OQ-60** (open): Where does transport construction live? The
endpoint-as-accept-loop-runner boundary (ADR-083) commits to
construction being outside the endpoint, but the destination —
assembly layer, `alknet-tls` convenience helpers, or a transport
module/crate — is open. `alknet-tls`'s stated job is "TLS setup, not
transport accept logic," which is an argument against the helper
option.
- **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.
- **OQ-60** (resolved): Where does transport construction live? The
TCP+TLS accept loop lives in `alknet-core` behind a `tcp` feature as
an owned endpoint transport (`with_tcp_tls`). Builder functions are
inlined by the assembly layer. See ADR-083.
- **OQ-61** (dissolved): Multi-owner shutdown coordination. The
problem does not arise — the endpoint owns all its accept loops
(quinn, iroh, TCP+TLS); `shutdown()` stops them all. See ADR-083.
## References
@@ -392,8 +401,9 @@ See [open-questions.md](../../open-questions.md) for full details.
— client-side verifier selection (CA vs fingerprint pin)
- `docs/architecture/decisions/065-connection-from-stream-generic-single-stream.md`
— `Connection::from_stream`/`from_bidi` (TCP+TLS path)
- `docs/architecture/decisions/010-alpn-router-and-endpoint.md` Amendment 1
— TCP+TLS dispatch via `from_stream` (accept loop outside the endpoint struct)
- `docs/architecture/decisions/010-alpn-router-and-endpoint.md`
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
(TLS section will be amended to point to `alknet-tls`)
- `docs/architecture/crates/core/config.md` — `TlsIdentity`, `StaticConfig`