Review of the three new crates (alknet-tls, alknet-endpoint, alknet-client) + revised core found compile-blocking inconsistencies, stale claims, and dep-graph contradictions. All resolved: Critical: - C1: CallClient::connect / ChannelClient::connect_quic REMOVED (not delegated) — keeping them as thin wrappers over AlknetClient::dial_quic would make protocol crates depend on alknet-client, contradicting the dep graph. Callers compose dial + take-over (2 lines). - C2: alknet-client feature gates now pull alknet-core/quinn + alknet-core/iroh (for Connection::from_quinn_with_alpn / from_iroh). - C3: rustls-native-certs + webpki-roots added to alknet-tls deps (always-present, not feature-gated — CA-verify path is transport-agnostic). Warning: - W1: CallCredentials/RemoteIdentity moved to alknet-core (from alknet-call) — the dial must not depend on the call protocol; not a two-way-door, it determines the dep graph. - W2: webpki-roots fallback implemented in spec (ADR-088 §5 added) — the code claimed a fallback that never existed; now the store is never empty, NoRootAnchors unreachable, containerized deployments work. - W3: EndpointError removed entirely (BindFailed + HandlerNotFound both vestigial after ADR-083); shutdown() is now infallible. - W4: FingerprintPinVerifier moved to alknet-tls (from alknet-call) — alknet-call sheds quinn/rustls/rustls-pemfile/rustls-native-certs entirely; CallClient becomes a pure protocol crate. Plus: ClientError removed (only produced by removed connect); S1 (CallCredentials → ClientVerifierContext mapping + auth_token stripped at TLS boundary documented); amendment notes on ADR-017, ADR-069, ADR-080, ADR-082, ADR-087, ADR-090; overview crate graph + README index updated. 29 files, consistency-reviewed.
652 lines
33 KiB
Markdown
652 lines
33 KiB
Markdown
# ADR-083: Endpoint as Multi-Transport Accept-Loop Runner with Public Dispatch
|
||
|
||
## Status
|
||
|
||
Accepted (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".
|
||
All TLS-scope OQs resolved; advanced to Accepted ahead of the
|
||
task-decomposition session.)
|
||
|
||
Accepted (amended 2026-07-15: the endpoint is extracted from
|
||
`alknet-core` into a new crate `alknet-endpoint`. The ADR's shape
|
||
(`new` + builder methods + public `dispatch` + `run` / `shutdown`) is
|
||
unchanged; the *location* changes. See §"Amendment 2026-07-15 — crate
|
||
extraction".)
|
||
|
||
## Context
|
||
|
||
`AlknetEndpoint` (ADR-010) conflates two concerns:
|
||
|
||
1. **Transport construction** — building `quinn::Endpoint` and
|
||
`iroh::Endpoint`, reading `static_config.tls_identity`, constructing TLS
|
||
internally (`TlsSetup::new`, `build_rustls_server_config`), wrapping
|
||
rustls into `quinn::ServerConfig`, binding.
|
||
2. **Dispatch** — take a connection, extract its ALPN, look up the
|
||
handler, build `AuthContext`, spawn the handler.
|
||
|
||
The conflation was tolerable while TLS was welded to quinn (one transport,
|
||
one cert path). Two things surfaced it:
|
||
|
||
- **ADR-082 (`alknet-tls` extraction)** pulls quinn's TLS construction
|
||
out of the endpoint into a shareable `TlsServerConfig`. Once the cert
|
||
is built outside the endpoint, the endpoint's transport-construction
|
||
role is the odd one out — it's the only thing still reading
|
||
`static_config` and building transports.
|
||
- **A hub holds two `TlsServerConfig`s**, not one (raw key for native
|
||
clients on QUIC + TCP+TLS fallback; X.509/ACME for HTTPS on TCP+TLS).
|
||
ADR-082's original signature `AlknetEndpoint::new(..., Arc<TlsServerConfig>)`
|
||
cannot represent this — a hub has two configs and the endpoint has no
|
||
single "the TLS config" to take. The TLS extraction surfaces this
|
||
because the endpoint can no longer build the one cert it used to own.
|
||
|
||
### The dispatch logic is the endpoint's real value
|
||
|
||
Stripped of transport construction, what the endpoint provides is
|
||
**dispatch**: take a connection, extract its ALPN, look up the handler,
|
||
build `AuthContext`, spawn. This is identical for every transport. The
|
||
transport-specific parts are narrow bookends:
|
||
|
||
- **ALPN extraction** — quinn: `handshake_data().protocol`; iroh:
|
||
`connecting.alpn().await`; TCP+TLS: `tls_stream.alpn()`.
|
||
- **Fingerprint extraction** — quinn: `peer_identity()` →
|
||
`CertificateDer`; iroh: `remote_id()` → `ed25519:<hex>`; TCP+TLS:
|
||
`tls_stream.peer_certificates()` → `CertificateDer`.
|
||
- **Connection construction** — quinn: `from_quinn_with_alpn`; iroh:
|
||
`from_iroh`; TCP+TLS: `from_bidi(tls_stream, alpn, remote_addr)`.
|
||
- **No-handler close** — quinn/iroh: `connection.close()`; TCP+TLS: drop
|
||
the stream (the `Connection::close` API handles both uniformly —
|
||
ADR-065).
|
||
|
||
Everything between those bookends — the ACME ALPN guard, the handler
|
||
lookup, the `build_auth_context` call, the `tokio::spawn` — is identical.
|
||
|
||
### 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
|
||
endpoint because the endpoint's dispatch logic was crate-private and
|
||
couldn't be shared. The reason TCP+TLS was *structurally* excluded —
|
||
the endpoint built transports internally, and TCP+TLS couldn't fit that
|
||
shape — is gone after this ADR: 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:
|
||
|
||
| Transport | What `with_*` takes | Accept loop |
|
||
|-----------|---------------------|-------------|
|
||
| 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
|
||
|
||
ADR-027 §5 places the `acme-tls/1` early-return guard in
|
||
`dispatch_quinn`. The rationale (no handler for `acme-tls/1`; the
|
||
challenge is answered at the TLS layer; close gracefully) is correct.
|
||
The **location** is wrong once TCP+TLS exists.
|
||
|
||
ACME TLS-ALPN-01 (RFC 8737) challenges are validated by the CA
|
||
connecting **over TCP to port 443**. Let's Encrypt's validator is a TCP
|
||
TLS client — it does not speak QUIC. In any real hub deployment, the
|
||
ACME challenge arrives on the **TCP+TLS** listener (443), not the QUIC
|
||
listener (4433). Today the guard fires only because quinn happens to be
|
||
the listener that's bound to the ACME port — an artifact of TCP+TLS not
|
||
existing yet, not a property of QUIC.
|
||
|
||
The guard's job is transport-agnostic: "if the TLS handshake negotiated
|
||
`acme-tls/1`, this is a challenge connection — the cert was already
|
||
served at the TLS layer via `ResolvesServerCertAcme`, close it, no
|
||
handler." `Connection::close` is transport-abstracted (ADR-065), so
|
||
the guard works uniformly for quinn, iroh, and TCP+TLS. The right home is
|
||
the **shared `dispatch` method**, not `dispatch_quinn`. In practice only
|
||
the TCP+TLS listener on 443 receives challenges (CAs validate via TCP);
|
||
advertising `acme-tls/1` on a QUIC listener that shares the ACME config
|
||
is harmless — no QUIC client negotiates it.
|
||
|
||
### `StaticConfig`'s role shifts
|
||
|
||
`StaticConfig` today holds `listen_addr`, `tls_identity`, `iroh_relay`,
|
||
`drain_timeout`. After this ADR, the endpoint reads only `drain_timeout`
|
||
(passed directly to `new`); the assembly layer reads the rest to build
|
||
transports. `StaticConfig` becomes the canonical deployment config the
|
||
**assembly layer** reads, not "the thing the endpoint takes." It stays
|
||
in `alknet-core` (it's a config type) and stays extensible (future
|
||
transport config fields land here as the assembly layer needs them).
|
||
|
||
The "assembly layer" is, in practice, the deployment binary — today,
|
||
that's primarily the hub. A pure worker (no inbound endpoints) has a
|
||
trivial assembly layer; a hub-worker combines both. Putting
|
||
hub-specific composition in the hub crate is appropriate; putting
|
||
transport loops that any node might need in the hub crate is not. The
|
||
TCP+TLS loop belongs in `alknet-endpoint` (behind a `tcp` feature),
|
||
where any node that wants it can enable the feature and call
|
||
`with_tcp_tls` — no hub dependency.
|
||
|
||
## Decision
|
||
|
||
### The endpoint is a multi-transport accept-loop runner + a public dispatch method
|
||
|
||
```rust
|
||
pub struct AlknetEndpoint {
|
||
quinn: Option<quinn::Endpoint>,
|
||
iroh: Option<iroh::Endpoint>,
|
||
#[cfg(feature = "tcp")]
|
||
tcp_tls: Option<TcpTlsListener>, // (TcpListener, TlsAcceptor)
|
||
handlers: Arc<HandlerRegistry>, // ADR-010
|
||
dynamic: Arc<ArcSwap<DynamicConfig>>, // ADR-010, unchanged
|
||
identity_provider: Arc<dyn IdentityProvider>, // ADR-010, unchanged
|
||
shutdown_tx: watch::Sender<bool>,
|
||
shutdown_rx: watch::Receiver<bool>,
|
||
drain_timeout: Duration,
|
||
}
|
||
|
||
impl AlknetEndpoint {
|
||
pub fn new(
|
||
handlers: HandlerRegistry,
|
||
dynamic: Arc<ArcSwap<DynamicConfig>>,
|
||
identity_provider: Arc<dyn IdentityProvider>,
|
||
drain_timeout: Duration,
|
||
) -> Self;
|
||
|
||
pub fn with_quinn(mut self, endpoint: quinn::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
|
||
/// to signal any external dispatch callers (SSH, future WT) to stop.
|
||
/// The endpoint's own loops (quinn, iroh, TCP+TLS) are signaled
|
||
/// internally by `shutdown()`.
|
||
pub fn shutdown_sender(&self) -> watch::Sender<bool>;
|
||
|
||
/// Dispatch a pre-established connection by ALPN. Called by the
|
||
/// endpoint's own accept loops (quinn, iroh, TCP+TLS) after
|
||
/// transport-specific extraction, and by external dispatch callers
|
||
/// (SSH channels, future WebTransport streams) after their own
|
||
/// extraction.
|
||
///
|
||
/// Synchronous (non-async): performs the ACME guard, handler
|
||
/// lookup, `build_auth_context`, and `tokio::spawn`s the handler.
|
||
/// Returns immediately after spawning — the handler runs on its own
|
||
/// task. The caller's accept loop is not blocked by handler
|
||
/// execution.
|
||
///
|
||
/// No-handler match (ALPN not in the registry): closes the
|
||
/// connection and logs a warning, per ADR-010's existing dispatch
|
||
/// behavior. Does not panic, does not return an error — the
|
||
/// connection is dropped, the accept loop continues.
|
||
pub fn dispatch(
|
||
&self,
|
||
connection: Connection,
|
||
alpn: Vec<u8>,
|
||
fingerprint: Option<String>,
|
||
remote_addr: Option<SocketAddr>,
|
||
);
|
||
|
||
/// Run the endpoint's owned accept loops (quinn, iroh, TCP+TLS —
|
||
/// whichever were configured). Returns when shutdown is signaled.
|
||
/// The caller drives shutdown via `shutdown()` or `shutdown_sender()`.
|
||
pub async fn run(self: Arc<Self>);
|
||
|
||
/// Signal all owned accept loops to stop, drain in-flight handlers
|
||
/// for `drain_timeout`, then close. Infallible — the endpoint owns
|
||
/// all its accept loops (quinn, iroh, TCP+TLS) and takes pre-built
|
||
/// pre-bound transports, so there is no bind failure path and no
|
||
/// external loop coordination. No-handler matches are swallowed by
|
||
/// `dispatch` (close + log). One owner, one shutdown.
|
||
pub async fn shutdown(&self);
|
||
}
|
||
```
|
||
|
||
The consumer pattern: the assembly layer (the deployment binary — today
|
||
primarily the hub) holds `Arc<AlknetEndpoint>`, clones the `Arc` for
|
||
`run` (which consumes one clone), and — for external dispatch callers
|
||
like SSH — passes `&endpoint` to them so they can call `dispatch`. After
|
||
`run` returns (on shutdown), in-flight dispatched handlers drain per
|
||
`shutdown()`.
|
||
|
||
`AlknetEndpoint::new` takes **no `StaticConfig`** and **no TLS config**.
|
||
The assembly layer (the deployment binary that composes crates — see
|
||
ADR-014) reads `StaticConfig`, builds the transports, and hands the
|
||
quinn/iroh/TCP+TLS endpoints to `AlknetEndpoint` via builder methods.
|
||
The `handlers`, `dynamic`, and `identity_provider` fields are unchanged
|
||
passthroughs from ADR-010 — this ADR does not change auth or dynamic
|
||
config; it changes who builds transports and where dispatch lives.
|
||
|
||
### TCP+TLS is owned, not external
|
||
|
||
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-endpoint`** (behind a `tcp`
|
||
feature), not the hub crate or the assembly layer. The endpoint
|
||
crate 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,
|
||
fingerprint (if available), remote address (if available), and a
|
||
`Connection`. It performs the ACME guard, the handler lookup, the
|
||
`build_auth_context` call, and the `tokio::spawn`. `build_auth_context`
|
||
becomes a private helper called by `dispatch`, not a standalone
|
||
function — external callers call `dispatch`, which calls
|
||
`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`,
|
||
`extract_quinn_client_fingerprint`, `extract_iroh_client_fingerprint`,
|
||
`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.
|
||
|
||
### The `acme-tls/1` guard moves to `dispatch`
|
||
|
||
```rust
|
||
pub fn dispatch(&self, connection: Connection, alpn: Vec<u8>, ...) {
|
||
if alpn == b"acme-tls/1" {
|
||
debug!("acme-tls/1 challenge connection completed at TLS layer; closing");
|
||
connection.close(0, "acme done");
|
||
return;
|
||
}
|
||
// ... handler lookup, build_auth_context, spawn
|
||
}
|
||
```
|
||
|
||
The guard is **not** feature-gated on `acme`: if the `acme` feature is
|
||
off, no transport advertises `acme-tls/1`, the ALPN never negotiates, the
|
||
guard never fires. It is harmless dead code without the feature.
|
||
|
||
This supersedes ADR-027 §5's "in `dispatch_quinn`" location. The
|
||
rationale (no handler for `acme-tls/1`, silent close) holds; only the
|
||
location changes. The `acme-tls/1` ALPN append still happens in
|
||
`TlsServerConfig::new`'s ACME branch (ADR-082), not per-transport — every
|
||
transport using the ACME `TlsServerConfig` advertises it, but in
|
||
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-endpoint`).
|
||
|
||
### `StaticConfig` stays in core; the endpoint drops it
|
||
|
||
`StaticConfig` remains in `alknet-core/config.rs` as the canonical
|
||
deployment config the assembly layer reads. The endpoint no longer takes
|
||
`&StaticConfig` — `new()` takes `drain_timeout: Duration` directly.
|
||
Future transport config fields (addresses, relay URLs, identity) land in
|
||
`StaticConfig` as the assembly layer needs them; the endpoint's `new`
|
||
signature does not change when they're added, because the endpoint
|
||
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`
|
||
|
||
| Code | Destination |
|
||
|------|-------------|
|
||
| `build_rustls_server_config()` | `alknet-tls` (ADR-082) |
|
||
| `TlsSetup` / ACME state machine | `alknet-tls` (ADR-082) |
|
||
| `RawKeyCertResolver` | `alknet-tls` (ADR-082) |
|
||
| `Ed25519SigningKey` | `alknet-tls` (ADR-082) |
|
||
| `AcceptAnyCertVerifier` | `alknet-tls` (ADR-082) |
|
||
| `SelfSignedCert` / `generate_self_signed_cert()` | `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_iroh_endpoint()` | Assembly layer (inlined; 15 lines of iroh API calls) |
|
||
| `AlknetEndpoint`, `HandlerRegistry`, all dispatch/loop/extraction code | `alknet-endpoint` (this ADR, §"Amendment 2026-07-15") |
|
||
| `EndpointError` | **removed** — `BindFailed` and `HandlerNotFound` are both vestigial (endpoint takes pre-bound transports; `dispatch` swallows no-handler matches); `shutdown()` is infallible |
|
||
|
||
### What stays in `alknet-endpoint` (the new crate)
|
||
|
||
- `AlknetEndpoint` struct (multi-transport accept-loop runner + public `dispatch`)
|
||
- `HandlerRegistry`
|
||
- `TcpTlsListener` (the `(TcpListener, TlsAcceptor)` tuple for the TCP+TLS field)
|
||
- `dispatch` (public — ACME guard, handler lookup, `build_auth_context`, spawn)
|
||
- `dispatch_quinn` / `dispatch_iroh` / `dispatch_tcp_tls` (private — transport-specific extraction, then call `dispatch`)
|
||
- `run_quinn_accept_loop` / `run_iroh_accept_loop` / `run_tcp_tls_accept_loop`
|
||
- `extract_quinn_alpn` / `extract_quinn_client_fingerprint` /
|
||
`extract_iroh_client_fingerprint` / `extract_tcp_tls_alpn` /
|
||
`extract_tcp_tls_client_fingerprint`
|
||
- `build_auth_context` (private helper, called by `dispatch`)
|
||
|
||
### ADR-082 amendment
|
||
|
||
ADR-082's `AlknetEndpoint::new(..., Arc<TlsServerConfig>, ...)` signature
|
||
is superseded by this ADR. The endpoint takes no TLS config; the
|
||
assembly layer builds transports from `TlsServerConfig`s and hands them
|
||
to the endpoint via `with_quinn` / `with_iroh` / `with_tcp_tls`. ADR-082
|
||
should reference this ADR for the endpoint signature and focus on what
|
||
`alknet-tls` provides (`TlsServerConfig` and its accessors).
|
||
|
||
### Amendment 2026-07-15 — crate extraction (`alknet-endpoint`)
|
||
|
||
The endpoint is extracted from `alknet-core` into a new crate
|
||
`alknet-endpoint`. The ADR's shape — `new(handlers, dynamic,
|
||
identity_provider, drain_timeout)` + `with_quinn` / `with_iroh` /
|
||
`with_tcp_tls` + public `dispatch` + `run` / `shutdown` — is **unchanged**
|
||
by this amendment. Only the *location* changes: `endpoint.rs`,
|
||
`HandlerRegistry` move from `alknet-core` to `alknet-endpoint`
|
||
(`EndpointError` is removed — see above).
|
||
|
||
#### Why extract (the dependency data)
|
||
|
||
`alknet-core` is currently two things welded together with different
|
||
audiences:
|
||
|
||
| Concern | Modules | Depended on by |
|
||
|---------|---------|----------------|
|
||
| **Shared types + auth + config** | `types.rs`, `auth.rs`, `config.rs`, `fingerprint.rs`, `ownership.rs`, `store.rs` | every handler crate (call, http, tty, tty-local) — 124 import sites |
|
||
| **Endpoint (accept-loop runner)** | `endpoint.rs` (1606 LOC) | **zero handler crates** — only the assembly layer (hub/worker) |
|
||
|
||
The handler crates import `alknet_core::types` (57×), `alknet_core::auth`
|
||
(41×), `alknet_core::config` (13×), `alknet_core::ownership` (7×),
|
||
`alknet_core::fingerprint` (6×). They import `alknet_core::endpoint`
|
||
**zero times**. `endpoint.rs` is a one-way leaf consumer of the shared
|
||
types (it imports `auth`, `config`, `types`; nothing in core imports
|
||
*from* it). This is the textbook shape for an extraction: a leaf
|
||
consumer depended on by a different audience than the shared types.
|
||
|
||
#### The dependency-weight win
|
||
|
||
`alknet-core`'s `Cargo.toml` currently carries `quinn`, `iroh`,
|
||
`rustls-pemfile`, `rcgen`, `rustls-acme`, `rustls` — heavy transport/TLS
|
||
deps — because `endpoint.rs` uses them. Every handler crate transitively
|
||
pulls these via `alknet-core`, even though none of them use the
|
||
endpoint. `alknet-tty` is a pure wire-format handler — it has no business
|
||
linking quinn.
|
||
|
||
After extraction:
|
||
|
||
- **`alknet-core`** loses `quinn`, `iroh`, `rcgen`, `rustls-pemfile`,
|
||
`rustls-acme` from its `[dependencies]` (the TLS-setup deps already
|
||
move to `alknet-tls` per ADR-082; the transport deps move with the
|
||
endpoint). It keeps `rustls`/`rustls-pki-types` (narrow — for
|
||
`fingerprint.rs` types, per OQ-59), `ed25519-dalek`, `sha2`, `tokio`,
|
||
`serde`, `arc-swap`. It becomes a **lightweight types+auth+config
|
||
crate** — what the handler crates actually want.
|
||
- **`alknet-endpoint`** gains `quinn`, `iroh` (the accept-loop
|
||
transports). It depends on `alknet-core` for `Connection`,
|
||
`ProtocolHandler`, `AuthContext`, `IdentityProvider`, `DynamicConfig`.
|
||
It does **not** depend on `alknet-tls` — the endpoint takes pre-built
|
||
transports (per this ADR), so TLS config construction stays in
|
||
`alknet-tls` at the assembly layer.
|
||
|
||
A handler crate (call, http, tty) no longer transitively links quinn,
|
||
iroh, rcgen, or rustls-acme. A pure worker (client-only, no inbound)
|
||
depends on `alknet-client` + handler crates + `alknet-core` and does
|
||
**not** pull `alknet-endpoint` at all.
|
||
|
||
#### The `quinn` feature split
|
||
|
||
`alknet-core`'s `quinn` feature currently gates two unrelated things:
|
||
(1) `endpoint.rs`'s quinn accept loop and (2) `types.rs`'s
|
||
`Connection::from_quinn` / `QuinnBidiStreamSource` constructor. After
|
||
extraction:
|
||
|
||
- **`Connection::from_quinn` stays in `alknet-core`** (in `types.rs`).
|
||
It's a shared-type constructor used by both the endpoint's accept
|
||
loop (server) and `alknet-client`'s `dial_quic` (client, ADR-089) and
|
||
`CallClient` tests. The `quinn` feature on `alknet-core` gates this
|
||
constructor — "does `Connection` support quinn-constructed
|
||
connections" — not "does core run a quinn accept loop." Same for
|
||
`from_iroh` and the `iroh` feature.
|
||
- **The quinn accept loop moves to `alknet-endpoint`**, which has its
|
||
own `quinn` feature for the accept loop. The heavy quinn accept-loop
|
||
code is in `alknet-endpoint`, not core.
|
||
|
||
#### Symmetry with `alknet-client` (ADR-089)
|
||
|
||
The extraction mirrors the client-side extraction (ADR-089):
|
||
|
||
| Server side | Client side | Audience |
|
||
|-------------|-------------|----------|
|
||
| `alknet-endpoint` (this ADR) | `alknet-client` (ADR-089) | the assembly layer (hub, worker, hub-worker) |
|
||
| `alknet-core` (shared types) | `alknet-core` (shared types) | every handler crate |
|
||
|
||
A **hub** depends on `alknet-endpoint` + `alknet-client` + handler
|
||
crates + `alknet-core` (transitively). A **pure worker** depends on
|
||
`alknet-client` + handler crates + `alknet-core` — no `alknet-endpoint`.
|
||
A **handler crate** depends on `alknet-core` only — neither endpoint
|
||
nor client.
|
||
|
||
#### Implementation: build new, delete old
|
||
|
||
The endpoint's `endpoint.rs` is 1606 lines of `#[cfg(feature = "quinn")]`
|
||
/ `#[cfg(feature = "iroh")]` / `#[cfg(feature = "acme")]` conditionals —
|
||
the TLS-setup code that ADR-082 moves to `alknet-tls`, the
|
||
`StaticConfig`-taking constructor that this ADR replaces, the
|
||
`acme-tls/1` guard that this ADR moves to shared `dispatch`. Building
|
||
`alknet-endpoint` fresh against this ADR's shape — `new()` + builder
|
||
methods + public `dispatch` + `run` / `shutdown`, importing
|
||
`Connection`/`ProtocolHandler`/`AuthContext` from `alknet-core`, no TLS
|
||
setup (that's `alknet-tls`'s job now) — is cleaner than editing the
|
||
1606-line conditional file in place. The old `endpoint.rs` becomes a
|
||
deletion, not a refactor. This is pruning (cutting the endpoint + the
|
||
TLS deps that already move to `alknet-tls`), not a massive inline
|
||
refactor.
|
||
|
||
#### What `alknet-core` looks like after
|
||
|
||
`alknet-core` after the extraction + the ADR-082 TLS pruning:
|
||
|
||
| Module | LOC | Stays? |
|
||
|--------|-----|--------|
|
||
| `types.rs` (`Connection`, `ProtocolHandler`, `BiStream`, `BidiStreamSource`, `StreamError`, `Capabilities`) | ~1100 | yes — shared by every crate |
|
||
| `auth.rs` (`AuthContext`, `Identity`, `IdentityProvider`, `IdentityStore`, `AuthToken`) | ~640 | yes — shared by every crate |
|
||
| `config.rs` (`StaticConfig`, `DynamicConfig`, `TlsIdentity`, `Ed25519SecretKey`) | ~710 | yes — shared config types |
|
||
| `fingerprint.rs` | ~260 | yes — shared by server + client (OQ-59) |
|
||
| `ownership.rs` (`OwnershipStore`, `OwnershipProvider`) | ~280 | yes — shared |
|
||
| `store.rs` (`CredentialStore`, `EncryptedData`) | ~200 | yes — shared |
|
||
| `endpoint.rs` (`AlknetEndpoint`, `HandlerRegistry`) | ~1600 | **no** — moves to `alknet-endpoint` (`EndpointError` is removed, not moved) |
|
||
|
||
Core loses ~1600 LOC (the endpoint) and 5 heavy deps (`quinn`, `iroh`,
|
||
`rcgen`, `rustls-pemfile`, `rustls-acme`). The remaining ~3200 LOC is
|
||
the lightweight types+auth+config+ownership+store+fingerprint surface
|
||
that every handler crate depends on.
|
||
|
||
## Consequences
|
||
|
||
**Positive:**
|
||
- The endpoint has one job: run accept loops + dispatch. Transport
|
||
construction lives outside it, where the multi-`TlsServerConfig` hub
|
||
case is natural.
|
||
- TCP+TLS is a first-class owned transport — same `run()` loop, same
|
||
`shutdown()`, same dispatch path as quinn/iroh. No duplicated
|
||
`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
|
||
(where challenges actually arrive) and any future transport. ADR-027
|
||
§5's quinn-specific location is corrected.
|
||
- `StaticConfig`'s role is clear: it's the assembly-layer config, not
|
||
the endpoint's. Adding transport config fields doesn't churn the
|
||
endpoint's `new` signature.
|
||
- The hub (the first multi-transport consumer) is unblocked: build two
|
||
`TlsServerConfig`s, build quinn/iroh/TCP+TLS listeners, hand all
|
||
three to the endpoint, `run()`.
|
||
- `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.
|
||
- **The endpoint is extracted into `alknet-endpoint`** (Amendment
|
||
2026-07-15). Handler crates no longer transitively link quinn, iroh,
|
||
rcgen, or rustls-acme. A pure worker (client-only) does not pull the
|
||
endpoint at all. The dep graph is symmetric with `alknet-client`
|
||
(ADR-089): `alknet-core` is the shared types crate; `alknet-endpoint`
|
||
and `alknet-client` are the server-side and client-side establishment
|
||
crates that depend on it.
|
||
|
||
**Negative:**
|
||
- `AlknetEndpoint::new` signature changes (breaking). Pre-1.0, in-repo
|
||
consumers only — the assembly layer and tests must update. Expected;
|
||
this is the point of the refactor.
|
||
- `alknet-endpoint` gains a `tcp` feature (pulls `tokio-rustls`). This is
|
||
the same pattern as the `quinn` and `iroh` features — a transport that
|
||
the endpoint can own. A deployment that doesn't use TCP+TLS doesn't
|
||
enable it. `alknet-core` keeps a narrow `rustls` dep (for
|
||
`fingerprint.rs` types, per OQ-59); `tokio-rustls` is the acceptor
|
||
wrapper over `rustls::ServerConfig`, not a separate TLS stack, and
|
||
lives in `alknet-endpoint`, not core.
|
||
- `build_iroh_endpoint` leaves core (inlined by the assembly layer).
|
||
This is a one-way dep-graph change: the binary that uses iroh depends
|
||
on `iroh` directly, not via `alknet-core` or `alknet-endpoint`. This
|
||
is correct — the binary *is* the thing that knows which transports it
|
||
wants. The 15 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
|
||
|
||
**One-way.** The endpoint's `new` signature, the public `dispatch`
|
||
contract, the "endpoint owns dispatch, not construction" boundary, the
|
||
"endpoint owns TCP+TLS as a first-class transport" decision, and the
|
||
extraction of the endpoint into `alknet-endpoint` (a separate crate
|
||
from `alknet-core`) are structural. Reversing would mean re-welding
|
||
transport construction to the endpoint, re-privatizing `dispatch`,
|
||
pushing TCP+TLS back outside, and re-merging the endpoint into core —
|
||
breaking every multi-transport consumer (hub, hub-worker, future SSH,
|
||
future WT) and re-coupling every handler crate to quinn/iroh/rcgen.
|
||
|
||
The `dispatch` signature (`connection, alpn, fingerprint, remote_addr`)
|
||
is one-way — changing it after consumers exist is a rewrite. The
|
||
internal implementation (how extraction is factored, how `run` spawns
|
||
tasks, the TCP+TLS loop's internal structure) is two-way.
|
||
|
||
## References
|
||
|
||
- ADR-010: ALPN router and endpoint (amended — the endpoint no longer
|
||
constructs transports; "TCP is not an endpoint struct concern" is
|
||
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
|
||
"assembly layer" term — the deployment binary that composes crates)
|
||
- ADR-010 Amendment 1: TCP+TLS as sibling (superseded — TCP+TLS is now
|
||
an owned transport, not a sibling; `dispatch` is public for SSH/WT,
|
||
not for TCP+TLS)
|
||
- ADR-027 §5: ACME ALPN challenge handling (location amended — guard
|
||
moves from `dispatch_quinn` to shared `dispatch`)
|
||
- ADR-065: `Connection::from_stream`/`from_bidi` (the primitive that
|
||
makes TCP+TLS dispatch possible; `Connection::close` is
|
||
transport-abstracted, so the `acme-tls/1` guard works uniformly)
|
||
- ADR-082: `alknet-tls` extraction (amended — endpoint takes no
|
||
`Arc<TlsServerConfig>`; the assembly layer builds transports from
|
||
`TlsServerConfig`s)
|
||
- ADR-080: `ChannelClient::from_connection` (the transport-agnostic
|
||
client pattern this ADR mirrors on the server side)
|
||
- `docs/research/alknet-endpoint-refactor/findings.md` — the analysis
|
||
that surfaced the conflation and the two-config hub case
|
||
- `docs/architecture/crates/endpoint/README.md` — the canonical spec for
|
||
`alknet-endpoint` (this ADR's amendment is the decision; that spec is
|
||
the description)
|
||
- `crates/alknet-core/src/endpoint.rs` — the code being extracted into
|
||
`alknet-endpoint` (the old module becomes a deletion after the
|
||
extraction)
|
||
- OQ-60: resolved — transport construction is inlined by the assembly
|
||
layer (the deployment binary); the TCP+TLS loop lives in
|
||
`alknet-endpoint` behind a `tcp` feature as an owned transport
|
||
- OQ-61: dissolved — the multi-owner shutdown problem does not arise;
|
||
the endpoint owns all its accept loops (quinn, iroh, TCP+TLS) |