docs(arch): extract AlknetEndpoint into alknet-endpoint (ADR-083 Am. 2026-07-15)

Amend ADR-083 with the crate-extraction decision: the endpoint moves
from alknet-core into a new crate alknet-endpoint, mirroring the
alknet-client extraction (ADR-089). The ADR's shape (new + builder
methods + public dispatch + run/shutdown) is unchanged; only the
location changes.

The extraction is structural pruning, not an inline refactor. The
endpoint is a leaf consumer of core's shared types (zero handler crates
import it; 124 import sites for the other core modules). Extracting it
lets core shed quinn/iroh/rcgen/rustls-acme — handler crates no longer
transitively link those. A pure worker (client-only) does not pull
alknet-endpoint at all. The dep graph is symmetric: alknet-core is the
shared types crate; alknet-endpoint and alknet-client are the
server-side and client-side establishment crates.

New spec: crates/endpoint/README.md (the canonical endpoint spec).
core/endpoint.md is deprecated to a stub. Cross-references updated
across 8 docs (README, overview, tls, hub, client, core README, ADR-083,
ADR-089 references). Architecture review passed (3 critical, 9 warnings
— all addressed).
This commit is contained in:
glm-5.2 committed 2026-07-15 13:19:30 +00:00
1 parent ce7de57973
commit 8669594661
9 files changed
+667 -457

No files matched your search

+2 -2
View File
@@ -426,7 +426,7 @@ alknet-hub (uses AlknetClient for outbound worker dials)
├── alknet-client (the dial — the hub's dial_worker closure calls it)
├── alknet-channels-call (ChannelClient — the take-over)
├── alknet-call (CallAdapter, Dispatcher)
└── alknet-core (AlknetEndpoint)
└── alknet-endpoint (AlknetEndpoint)
alknet-worker (uses AlknetClient to dial a hub)
├── alknet-client (the dial)
@@ -533,7 +533,7 @@ See [open-questions.md](../../open-questions.md) for full details.
`ChannelClient::from_connection` (the take-over the dial feeds)
- [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md)
— `CallClient::spawn_dispatch` (the take-over the dial feeds)
- [`crates/core/endpoint.md`](../core/endpoint.md) — `AlknetEndpoint`
- [`crates/endpoint/README.md`](../endpoint/README.md) — `AlknetEndpoint`
(the server-side complement)
- [`crates/tls/README.md`](../tls/README.md) — `TlsClientConfig`
- [`crates/call/client-and-adapters.md`](../call/client-and-adapters.md)
+18 -8
View File
@@ -1,18 +1,27 @@
---
status: draft
last_updated: 2026-07-12
last_updated: 2026-07-15
---
# alknet-core
Core library for ALPN-based protocol dispatch. Every handler crate depends on alknet-core.
Shared types, auth, config, and identity for ALPN-based protocol
dispatch. Every handler crate depends on `alknet-core` for
`ProtocolHandler`, `Connection`, `AuthContext`, `IdentityProvider`, and
config types. The endpoint (`AlknetEndpoint`, `HandlerRegistry`,
`EndpointError`) has been extracted to
[`alknet-endpoint`](../endpoint/README.md) (ADR-083 Amendment
2026-07-15); core no longer carries the accept-loop runner or its
transport deps (quinn, iroh, rcgen, rustls-acme). `Connection::from_quinn`
/ `from_iroh` stay in core's `types.rs` as shared constructors (gated
on core's `quinn` / `iroh` features).
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [core-types.md](core-types.md) | draft | ProtocolHandler trait, HandlerError, Connection (`Box<dyn BidiStreamSource>` — ADR-070), BidiStreamSource trait, BiStream, StreamError |
| [endpoint.md](endpoint.md) | draft | ALPN router, HandlerRegistry, accept loop, graceful shutdown |
| [endpoint.md](endpoint.md) | deprecated | Endpoint spec — **moved to [`alknet-endpoint`](../endpoint/README.md)** (ADR-083 Am. 2026-07-15); this file is a stub |
| [auth.md](auth.md) | draft | AuthContext (incl. `anonymous` constructor), Identity, IdentityProvider, AuthToken, resolution flow, PeerEntry, CredentialStore |
| [config.md](config.md) | draft | StaticConfig, DynamicConfig, ArcSwap, ConfigReloadHandle, AuthPolicy.peers |
@@ -27,7 +36,7 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
| [006](../../decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | ALPN format, one-ALPN-per-connection |
| [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | Connection, BiStream trait, SendStream, RecvStream |
| [009](../../decisions/009-one-way-door-decision-framework.md) | One-Way Door Framework | Decision classification |
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Endpoint, HandlerRegistry, accept loop |
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | HandlerRegistry, accept loop — endpoint extracted to `alknet-endpoint` per ADR-083 Am. 2026-07-15 |
| [011](../../decisions/011-authcontext-structure.md) | AuthContext Structure | AuthContext fields and resolution flow |
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | Per-request identity on OperationContext; admin scope for config reload |
| [030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `authorized_fingerprints` → `peers: Vec<PeerEntry>`; `Identity.id` = `peer_id` (stable) |
@@ -35,25 +44,26 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
| [033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Core defines traits + in-memory defaults; persistence adapters are separate crates |
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` — Generic Single-Stream Connections | `from_stream`/`from_bidi` accept any `AsyncRead + AsyncWrite`; yield-once `accept_bi` contract; unblocks TCP+TLS, SSH channels, WebTransport, wasm |
| [070](../../decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait — Open Connection for Extension | `Connection` holds `Box<dyn BidiStreamSource>`; QUIC/iroh/stream wrap crate-private impls; `from_source` is the public constructor for downstream crates that implement the trait (channels, future transports) |
| [083](../../decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as accept-loop runner + crate extraction | The endpoint is extracted from core into `alknet-endpoint`; core loses `quinn`/`iroh`/`rcgen`/`rustls-acme` deps; `Connection::from_quinn`/`from_iroh` stay in core as shared constructors |
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-04 | Dynamic handler registration | resolved (start static) | HandlerRegistry is immutable at startup |
| OQ-05 | Multi-connectivity endpoint | resolved (quinn + iroh) | AlknetEndpoint supports both, both feature-gated |
| OQ-04 | Dynamic handler registration | resolved (start static) | HandlerRegistry is immutable at startup (now in `alknet-endpoint`) |
| OQ-05 | Multi-connectivity endpoint | resolved (quinn + iroh) | AlknetEndpoint supports both, both feature-gated (now in `alknet-endpoint`) |
| OQ-11 | Handler-level auth resolution observability | resolved | Handlers store resolved identity on Connection; two identity scopes (connection-level for observability, per-request for ACL) |
| OQ-33 | PeerId — logical id vs crypto identity | resolved by ADR-030 | `PeerId` = `Identity.id` = `PeerEntry.peer_id` (stable across key rotation) |
| OQ-34 | Persistent peer registry (storage boundary) | resolved by ADR-030+031+033 | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
| OQ-35 | ~~API key asymmetry~~ | dissolved | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
| OQ-36 | Concrete persistence adapter shapes | resolved by ADR-035 | Read-sync / write-async split (`IdentityStore`); SQLite adapter caches in memory, honker NOTIFY for no-restart cache invalidation; `alknet-store-sqlite` crate |
| OQ-37 | X.509 outgoing-only case | resolved by ADR-034 | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; client-side verifier by `PeerEntry` presence (CA vs fingerprint pin) |
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved by ADR-089 | The native dial seam is extracted as `alknet-client` — the client-side analogue of `AlknetEndpoint`. Three dial methods (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key). The client take-over APIs (`CallClient::spawn_dispatch`, `ChannelClient::from_connection` — ADR-080) are transport-agnostic and decided; the shared dial is now extracted. |
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved by ADR-089 | The native dial seam is extracted as `alknet-client` — the client-side analogue of `AlknetEndpoint` (now in `alknet-endpoint`). Three dial methods (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key). |
## Key Design Principles
1. **One trait, one dispatch point**: `ProtocolHandler` is the only abstraction handlers implement. No StreamInterface/MessageInterface split.
2. **ALPN does the routing**: The endpoint dispatches by ALPN string. No byte-peeking, no ListenerConfig enum.
2. **ALPN does the routing**: The endpoint (in `alknet-endpoint`) dispatches by ALPN string. No byte-peeking, no ListenerConfig enum.
3. **Handlers own their wire format**: Each handler manages its own protocol parsing. alknet-core provides the Connection, not the framing.
4. **Auth is hybrid**: The endpoint provides what it can (TLS-level auth). Handlers complete what they need. AuthContext may be partial.
5. **WASM door preserved**: BiStream is a trait, Connection is an opaque type. Core types don't assume tokio or quinn in public APIs.
+40 -389
View File
@@ -1,393 +1,44 @@
---
status: draft
status: deprecated
last_updated: 2026-07-15
---
# Endpoint
ALPN router, handler registry, connection accept loops, multi-connectivity, and graceful shutdown.
See [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) for the full rationale.
## AlknetEndpoint
The central runtime type. Manages one or more QUIC connection sources, each feeding into the same ALPN router.
```rust
pub struct AlknetEndpoint {
// 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_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:
| Source | Requires | Identity source | Use case |
|--------|----------|-----------------|----------|
| `quinn::Endpoint` | Public IP, TLS cert | TLS cert (network), SSH key (auth) | VPS, replicators, service hosts |
| `iroh::Endpoint` | Relay access | NodeId (Ed25519) | Home servers, NAT, IoT |
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.
> **Terminology — hub, worker, hub-worker.** A *hub* accepts inbound
> connections from workers and browsers (see
> [`crates/hub/README.md`](../hub/README.md) for the hub-and-spoke
> topology). A *worker* dials out to a hub. A *hub-worker* does both. A
> *pure worker* has no inbound endpoints. These terms come from ADR-029
> / ADR-034; "assembly layer" (ADR-014) is the deployment binary that
> wires crates — in practice, today, usually a hub or hub-worker.
### TCP+TLS is a first-class owned transport
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 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 `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
Maps ALPN byte strings to `ProtocolHandler` instances.
```rust
pub struct HandlerRegistry {
handlers: HashMap<&'static [u8], Arc<dyn ProtocolHandler>>,
}
impl HandlerRegistry {
pub fn new() -> Self;
pub fn register(&mut self, handler: Arc<dyn ProtocolHandler>);
pub fn get(&self, alpn: &[u8]) -> Option<&Arc<dyn ProtocolHandler>>;
pub fn alpn_strings(&self) -> Vec<Vec<u8>>;
}
```
- `register()`: Insert a handler. Panics if the ALPN is already registered.
- `get()`: Look up a handler by ALPN string.
- `alpn_strings()`: Return all registered ALPN strings. Used to build the TLS `ServerConfig` (for quinn) and the ALPN list (for iroh).
Registration is static at startup (see [OQ-04](../../open-questions.md)). The CLI builds a `HandlerRegistry`, inserts all handlers, and passes it to `AlknetEndpoint::new()`.
### ALPN strings in TLS ServerConfig and iroh endpoint
ALPN-list construction is the **assembly layer's** responsibility, not
the endpoint's. After ADR-083, the endpoint takes no TLS config and
builds no transports — the assembly layer reads `registry.alpn_strings()`
and passes the appropriate ALPN list to each `TlsServerConfig::new()`
(see [`crates/tls/README.md`](../tls/README.md)).
The ALPN list is **split by endpoint type** (ADR-086 §3, resolving
OQ-62): each `TlsServerConfig` advertises only the ALPNs its endpoint
type's client class can negotiate. The native config (raw key) gets
`alknet/channels`, `alknet/call`, `alknet/ssh` (future); the web config
(X.509/ACME) gets `h2`, `http/1.1`, `alknet/channels` (for
WebSocket-carrying-channels, OQ-65); the iroh builder gets
`alknet/channels`, `alknet/call`. The distinction is between
**entry points** (ALPNs accepted without identity — `h2`/`http/1.1`,
future `alknet/register`) and **endpoints** (ALPNs requiring identity —
`alknet/channels`, `alknet/call`, `alknet/ssh`). See
[ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) for
the full model and ALPN-list table.
The iroh endpoint's ALPN list is set via `iroh::Endpoint::builder().alpns()`
by the assembly layer at construction time, from the same
`registry.alpn_strings()` source (filtered to the iroh endpoint type's
ALPNs).
## Accept Loops
Each active connection source runs its own accept loop. All loops dispatch through the same `HandlerRegistry`:
### Quinn accept loop (public QUIC+TLS)
```
loop {
tokio::select! {
incoming = quinn_endpoint.accept() => {
let connection = incoming.await; // TLS handshake + ALPN negotiation
match connection {
Ok(conn) => dispatch(conn),
Err(e) => { /* log TLS handshake failure, continue */ }
}
}
_ = shutdown.changed() => break,
}
}
```
### iroh accept loop (P2P relay-assisted)
iroh's `Endpoint` natively supports ALPN negotiation (step 4 of its connection establishment). The `iroh::Endpoint::set_alpns()` method configures which ALPNs the endpoint advertises — the same mechanism iroh's own `Router` uses internally with its `ProtocolMap`.
We use `iroh::Endpoint` directly (not iroh's `Router`) because our `HandlerRegistry` is shared between quinn and iroh connection sources, and our `AuthContext` construction differs per source. Our accept loop replaces iroh's `Router` accept loop with our own dispatch:
```
loop {
tokio::select! {
incoming = iroh_endpoint.accept() => {
// incoming is an iroh::endpoint::Incoming
let accepting = incoming.accept(); // Accepting state
let alpn = accepting.alpn().await; // ALPN from TLS handshake
match alpn {
Ok(alpn) => dispatch(alpn, accepting),
Err(e) => { /* log handshake failure, continue */ }
}
}
_ = shutdown.changed() => break,
}
}
```
See iroh's `protocol.rs` (`/workspace/iroh/iroh/src/protocol.rs`) for the reference implementation of this pattern — `handle_connection()` reads the ALPN, looks up the handler in `ProtocolMap`, and calls `handler.accept(connection)`. Our dispatch is the same pattern with our `HandlerRegistry`.
### 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.
```
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 = build_auth_context(&alpn, remote_addr, fingerprint, &identity_provider);
tokio::spawn(async move {
if let Err(e) = handler.handle(connection, &auth).await {
// log error, connection closes
}
});
}
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.
- **No per-handler accept loops**: The old `ListenerConfig` enum had Stream/Http/Dns variants with different accept paths. ALPN unifies this.
- **No SSH-specific logic**: The accept loop is ALPN-agnostic. It doesn't know or care what protocol the handler speaks.
## Stealth Mode as ALPN Dispatch
The reference implementation's "stealth mode" is SSH-over-TLS on port 443. The TLS cert is **camouflage**, not identity — it makes the port look like a web server to port scanners and DPI systems. Non-SSH traffic gets a fake nginx 404.
In the ALPN model, this maps to:
- The `alknet/http` handler is registered for standard HTTP ALPNs (`h2`, `http/1.1`)
- The HTTP handler can serve a decoy website or a fake 404
- Real services use `alknet/ssh`, `alknet/call`, etc.
- Clients that don't offer alknet ALPNs get the HTTP handler — just like port scanners in stealth mode
No byte-peeking, no `ProtocolDetection` enum. ALPN does the routing.
## Network Identity vs Auth Identity
A key distinction that the ALPN model makes explicit:
| Layer | Purpose | Mechanism |
|-------|---------|-----------|
| **Network identity** | How a client finds and verifies the node | X.509 cert (domain) or RFC 7250 raw key (Ed25519) or iroh NodeId |
| **Auth identity** | Who the peer is and what they can do | SSH key, API token, certificate (handlers) |
The TLS cert (or raw public key, or NodeId) is the node's network-facing identity. It's NOT the node's authentication identity. Auth happens inside the handler via `IdentityProvider`.
This matches the reference implementation: the TLS cert encrypts and camouflages, but SSH key exchange handles the actual authentication.
## RFC 7250: Raw Public Keys in TLS
RFC 7250 raw public keys are the **default TLS identity mode** for most alknet nodes. They eliminate the need for domain names, CAs, and certificate renewal — the Ed25519 public key IS the node's identity.
iroh uses this model with its `NodeId`. The implementation is ~100 lines (see `iroh/iroh/src/tls/resolver.rs`): take an Ed25519 key, wrap its SPKI public key as a `CertificateDer`, tell rustls `only_raw_public_keys() -> true`. No X.509, no CAs, no domain names, no cert renewal.
Key implications:
- **Default for alknet-native clients**: SSH, git, and alknet-native clients all work with raw Ed25519 keys out of the box. The same key type used for SSH auth can serve as the TLS identity. This is the most common deployment mode.
- **No domain required**: A node without a domain name uses raw public keys for the quinn path — key-based identity with direct QUIC over UDP.
- **Key = identity**: The Ed25519 public key IS the node's identity. No CA trust chain, no cert expiry. The key can be derived from alknet-vault.
- **X.509 is for domain-hosted services**: Domain-facing identity (replicators, public services, browsers) uses X.509 certs. This is a separate use case, not the default.
- **Browser limitation**: Browsers don't support RFC 7250. For browser/WebTransport clients, X.509 certs are needed. For all other clients, raw public keys work fine.
The quinn and iroh paths share the same key-based identity model via RFC 7250. They're distinguished by **connection establishment** (direct UDP vs relay-assisted), not by identity:
| Path | Connection establishment | Default identity | Alternative identity |
|------|------------------------|-----------------|---------------------|
| quinn | Direct UDP, public IP | RFC 7250 raw key (most nodes) | X.509 cert (domain-hosted, browsers) |
| iroh | Relay-assisted P2P | RFC 7250 raw key (NodeId) | N/A |
## TLS Identity
TLS identity in alknet has two distinct use cases, each with a different trust model and provisioning mechanism. See OQ-12 for the full rationale.
### Use case 1: P2P / Key-based identity (default)
Most alknet nodes use RFC 7250 raw Ed25519 public keys for TLS identity. No domain name, no CA, no certificate renewal. The Ed25519 public key IS the node's identity — the same key model as iroh's `NodeId`, but for direct QUIC connections.
`TlsIdentity::RawKey` in `StaticConfig` configures this mode. The endpoint builds a `rustls::ServerConfig` with `only_raw_public_keys() -> true` and a `ResolvesServerCert` that generates the certificate on-the-fly from the key, exactly as iroh does (see `iroh/iroh/src/tls/resolver.rs`).
This mode works natively with SSH auth (same key type) and git (SSH key-based auth). It is the default for alknet-native clients. **Browser/WebTransport clients do not support RFC 7250** — they require X.509 certificates.
### Use case 2: Domain-hosted services
Nodes that serve browser/WebTransport clients, or nodes with public domain names, use X.509 certificates. This has two sub-cases:
- **Manual**: Provide cert/key file paths via `TlsIdentity::X509`. The endpoint loads them at startup and builds a standard `rustls::ServerConfig`.
- **ACME auto-provisioning**: Let's Encrypt via `rustls-acme`. `TlsIdentity::Acme { domains, cache_dir, directory, contact }` carries the static config; the endpoint constructs the `AcmeState` async state machine and `ResolvesServerCertAcme` at setup time (ADR-027). The `acme` feature gate keeps `rustls-acme` out of non-ACME builds. See [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) for the full design.
`TlsIdentity::SelfSigned` is for development only — the endpoint generates a self-signed cert on startup. External clients will not trust it.
### iroh endpoint identity
The iroh endpoint does not need TLS certificate configuration — it uses `NodeId` (Ed25519) for identity, which is RFC 7250 raw key identity built into the iroh endpoint.
### Identity model comparison
| Path | Identity model | Client compatibility | Use case |
|------|---------------|---------------------|----------|
| quinn + `TlsIdentity::RawKey` | RFC 7250 Ed25519 raw key | alknet-native, SSH, git | Personal nodes, P2P, most deployments |
| quinn + `TlsIdentity::X509` | X.509 domain certificate (manual) | All clients including browsers | Relays, public services, WebTransport |
| quinn + `TlsIdentity::Acme` | X.509 via ACME auto-provisioning | All clients including browsers | Public relays, domain-hosted services |
| quinn + `TlsIdentity::SelfSigned` | X.509 self-signed cert | None (dev only) | Local development |
| iroh | NodeId (Ed25519, RFC 7250 built-in) | alknet-native, iroh clients | NAT traversal, home servers |
Note: `TlsIdentity::RawKey` uses `Ed25519SecretKey` (alknet-core-owned,
backed by `ed25519-dalek`), not `iroh::SecretKey`. It is available in
quinn-only builds without the `iroh` feature. When the iroh transport is
also configured, `build_iroh_endpoint` converts the key to
`iroh::SecretKey::from_bytes` (ADR-027). The iroh dep is on `1.0`
(`default-features = false, features = ["tls-aws-lc-rs"]`, matching the
quinn path's aws-lc-rs crypto provider); migrated from `0.35` in commit
`acd049e` (2026-07-09) — 6 API surface edits, no architectural change.
## Graceful Shutdown
```rust
impl AlknetEndpoint {
pub fn shutdown_sender(&self) -> watch::Sender<bool>;
pub async fn shutdown(&self) -> Result<(), EndpointError>;
}
```
- `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 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
### EndpointError
Fatal errors that prevent the endpoint from starting or continuing.
```rust
pub enum EndpointError {
BindFailed(io::Error),
HandlerNotFound(Vec<u8>), // ALPN string with no registered handler
}
```
After ADR-083, the endpoint takes no TLS config and constructs no
transports — TLS config errors now surface as `TlsError` in
`alknet-tls` / the assembly layer (see
[`crates/tls/README.md`](../tls/README.md) and OQ-62), not as
`EndpointError`. The `TlsConfig(io::Error)` variant that existed when
the endpoint built TLS internally is removed. `BindFailed` covers
listener bind failures (quinn, iroh, TCP+TLS `TcpListener::bind`).
### HandlerError
Non-fatal errors within a handler. See [core-types.md](core-types.md) for details.
### Accept loop errors
- **TLS handshake failure**: Log and continue. The client may have offered no compatible ALPN, or the cert may be untrusted.
- **Handler panic**: Caught by tokio's task isolation. The connection is dropped. Other connections continue.
- **Connection-level errors** (quinn/iroh `ConnectionError`): Log and continue. The accept loop keeps running.
## Key Differences from Reference Implementation
| Aspect | Reference (`alknet-main`) | New Model |
|--------|---------------------------|-----------|
| Transport | `TransportAcceptor` trait, `TransportKind` enum | `quinn::Endpoint` + `iroh::Endpoint`, ALPN dispatch |
| Listener config | `ListenerConfig` enum (Stream/Http/Dns) | Single `HandlerRegistry`, ALPN dispatch |
| Protocol detection | Byte-peeking (`stealth::detect_protocol`) | ALPN negotiation (TLS layer) |
| Stealth mode | SSH-over-TLS with byte-peek | HTTP handler on `h2`/`http/1.1` serves decoy |
| Accept loop | Per-transport, SSH-centric | Per-connection-source, ALPN-agnostic |
| Handler model | `ServerHandler` + `russh::server::Handler` | `ProtocolHandler::handle(Connection, &AuthContext)` |
| Config | `ServeOptions` builder | `StaticConfig` + `HandlerRegistry` + `AlknetEndpoint::new()` |
| iroh | Separate `IrohAcceptor` + `IrohTransport` | `Option<iroh::Endpoint>` on `AlknetEndpoint` |
| Network vs auth identity | Conflated (TLS cert + SSH key both "auth") | Explicitly separated (TLS/NodeId = network, SSH key/token = auth) |
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| 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 |
| 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 |
| Handler panics isolated | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | tokio task isolation, connection closes |
| Endpoint types and entry points | [ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type |
## Open Questions
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-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).
# Endpoint (moved to `alknet-endpoint`)
> **This document is deprecated.** The `AlknetEndpoint`,
> `HandlerRegistry`, and `EndpointError` types have been extracted from
> `alknet-core` into a new crate `alknet-endpoint` (ADR-083 Amendment
> 2026-07-15). The canonical spec is now
> [`crates/endpoint/README.md`](../endpoint/README.md).
>
> The shared types the endpoint imports (`ProtocolHandler`,
> `Connection`, `AuthContext`, `IdentityProvider`, `DynamicConfig`) stay
> in `alknet-core` — see [`core-types.md`](core-types.md),
> [`auth.md`](auth.md), [`config.md`](config.md).
## Historical summary
The endpoint was originally in `alknet-core/endpoint.rs` as the central
runtime type — a multi-transport accept-loop runner that dispatches
incoming connections by ALPN (ADR-010, ADR-083). ADR-082 extracted the
TLS setup code to `alknet-tls`; ADR-083 restructured the endpoint to
take pre-built transports via `with_quinn` / `with_iroh` /
`with_tcp_tls` (no TLS config); ADR-083 Amendment 2026-07-15 extracted
the endpoint itself into `alknet-endpoint` so that handler crates no
longer transitively link quinn/iroh/rcgen via core.
The endpoint's semantics — ALPN dispatch, `HandlerRegistry`, accept
loops, public `dispatch` for SSH/WT, graceful shutdown — are unchanged
by the extraction. See
[`crates/endpoint/README.md`](../endpoint/README.md) for the current
spec and [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
for the full decision.
## What stayed in `alknet-core`
`Connection::from_quinn` / `from_iroh` stay in core's `types.rs` — they
are shared-type constructors used by both the endpoint's accept loop
(server) and `alknet-client`'s dial (client, ADR-089), gated on core's
`quinn` / `iroh` features. See
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) §"The
`quinn` feature split".
+384
View File
@@ -0,0 +1,384 @@
---
status: draft
last_updated: 2026-07-15
---
# alknet-endpoint
The server-side establishment crate — the multi-transport accept-loop
runner that dispatches incoming connections by ALPN. The server-side
analogue of `alknet-client` (ADR-089): `alknet-endpoint` accepts and
dispatches; `alknet-client` dials and produces a `Connection`. Both
depend on `alknet-core` for shared types; neither depends on the other.
## What
`AlknetEndpoint` is the central runtime type for any node that accepts
inbound connections. It takes pre-built transport endpoints (quinn,
iroh, TCP+TLS) via builder methods, runs their accept loops inside a
single `run()` method, and dispatches each accepted connection to the
registered `ProtocolHandler` by the ALPN the TLS handshake negotiated.
It does not build transports and does not build TLS configs — the
assembly layer does both (transports from `alknet-tls`'s
`TlsServerConfig`, per ADR-082).
`alknet-endpoint` is extracted from `alknet-core` (ADR-083 Amendment
2026-07-15). The extraction is structural pruning, not a refactor: the
endpoint is a leaf consumer of core's shared types (it imports `auth`,
`config`, `types`; nothing in core imports from it), depended on by a
different audience (the assembly layer) than the shared types (every
handler crate). No handler crate imports `AlknetEndpoint`,
`HandlerRegistry`, or `EndpointError` — they depend on `alknet-core`
for `ProtocolHandler`, `Connection`, `AuthContext`, and types only.
## Why
`alknet-core` was two things welded: shared types (depended on by every
handler crate) + the endpoint (depended on by zero handler crates).
Extracting the endpoint into `alknet-endpoint` lets core shed the heavy
transport deps (quinn, iroh, rcgen, rustls-acme) and become the
lightweight types+auth+config crate the handler crates actually want.
See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
§"Amendment 2026-07-15 — crate extraction" for the full rationale,
including the dependency data and the symmetry with `alknet-client`.
## Architecture
### `AlknetEndpoint`
```rust
pub struct AlknetEndpoint {
quinn: Option<quinn::Endpoint>,
iroh: Option<iroh::Endpoint>,
#[cfg(feature = "tcp")]
tcp_tls: Option<TcpTlsListener>, // (TcpListener, TlsAcceptor)
handlers: Arc<HandlerRegistry>,
dynamic: Arc<ArcSwap<DynamicConfig>>,
identity_provider: Arc<dyn IdentityProvider>,
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;
#[cfg(feature = "tcp")]
pub fn with_tcp_tls(
mut self,
listener: tokio::net::TcpListener,
acceptor: tokio_rustls::TlsAcceptor,
) -> Self;
pub fn shutdown_sender(&self) -> watch::Sender<bool>;
pub fn dispatch(
&self,
connection: Connection,
alpn: Vec<u8>,
fingerprint: Option<String>,
remote_addr: Option<SocketAddr>,
);
pub async fn run(self: Arc<Self>);
pub async fn shutdown(&self) -> Result<(), EndpointError>;
}
```
`new` takes **no `StaticConfig`** and **no TLS config** — the assembly
layer reads `StaticConfig` (in `alknet-core`), builds the transports
(via `alknet-tls`'s `TlsServerConfig` + the transport's own builder),
and hands them to the endpoint via `with_quinn` / `with_iroh` /
`with_tcp_tls`. The endpoint's job is to run accept loops and dispatch;
transport construction is not its concern. See
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) for
the full design.
### `HandlerRegistry`
Maps ALPN byte strings to `ProtocolHandler` instances. Registered
statically at startup by the assembly layer; the endpoint dispatches by
looking up the negotiated ALPN.
```rust
pub struct HandlerRegistry {
handlers: HashMap<&'static [u8], Arc<dyn ProtocolHandler>>,
}
impl HandlerRegistry {
pub fn new() -> Self;
pub fn register(&mut self, handler: Arc<dyn ProtocolHandler>);
pub fn get(&self, alpn: &[u8]) -> Option<&Arc<dyn ProtocolHandler>>;
pub fn alpn_strings(&self) -> Vec<Vec<u8>>;
}
```
- `register()`: Insert a handler. Panics if the ALPN is already registered.
- `get()`: Look up a handler by ALPN string.
- `alpn_strings()`: Return all registered ALPN strings. Used by the
assembly layer to build the TLS `ServerConfig`'s ALPN list (via
`alknet-tls`, filtered by endpoint type per ADR-086).
Registration is static at startup (ADR-010, OQ-04). The assembly layer
builds a `HandlerRegistry`, inserts all handlers, and passes it to
`AlknetEndpoint::new()`.
### `EndpointError`
The error type for `AlknetEndpoint::shutdown` and listener bind
failures. Lives in `alknet-endpoint` (moves with the endpoint from
core).
```rust
pub enum EndpointError {
BindFailed(io::Error),
HandlerNotFound(Vec<u8>), // ALPN string with no registered handler
}
```
After ADR-083, the endpoint takes no TLS config and constructs no
transports — TLS config errors surface as `TlsError` in `alknet-tls` /
the assembly layer, not as `EndpointError`. The `TlsConfig(io::Error)`
variant that existed when the endpoint built TLS internally is removed.
`BindFailed` covers listener bind failures (quinn, iroh, TCP+TLS
`TcpListener::bind`).
### `TcpTlsListener`
The type held by the endpoint's `tcp_tls` field — a tuple of the TCP
listener and the TLS acceptor:
```rust
type TcpTlsListener = (tokio::net::TcpListener, tokio_rustls::TlsAcceptor);
```
The endpoint owns both halves: `tcp.accept()` produces a `TcpStream`,
`tls.accept()` wraps it, then ALPN + fingerprint extraction →
`Connection::from_bidi` → `dispatch`. Feature-gated on `tcp`.
### Accept loops
Each active transport runs its own accept loop inside `run()`:
- **Quinn** — `quinn.accept()` → TLS handshake → extract ALPN +
fingerprint → `Connection::from_quinn_with_alpn` → `dispatch`.
- **Iroh** — `iroh.accept()` → `accepting.alpn().await` → extract
fingerprint → `Connection::from_iroh` → `dispatch`.
- **TCP+TLS** (behind `tcp` feature) — `tcp.accept()` →
`tls.accept()` → extract ALPN + fingerprint →
`Connection::from_bidi` → `dispatch`.
All three feed the same `dispatch` method. The transport-specific
extraction (ALPN, fingerprint, remote address) is private to the
endpoint; `dispatch` receives the extracted values. See
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
§"Accept Loops" for the loop pseudocode.
### `dispatch` (public)
The shared dispatch path for every transport — the endpoint's own
accept loops call it after transport-specific extraction, and external
dispatch callers (SSH channels, future WebTransport streams) call it
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.
`dispatch` is public for **connection-internal multiplexing shapes**
that the endpoint can't own (SSH channels: one connection, many
channels with different ALPNs; future WT streams: one QUIC connection,
many WT streams). Listener transports (quinn, iroh, TCP+TLS) are owned
by the endpoint and call `dispatch` internally; they are not external
dispatch callers. See
[ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
§"`dispatch` is public — for genuinely external shapes".
### Shutdown
`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. SIGTERM/SIGINT are wired to the
shutdown channel by the assembly layer (the deployment binary).
### What `alknet-endpoint` does NOT do
- **No transport construction.** The endpoint takes pre-built
transports via builder methods. The assembly layer builds them
(`TlsServerConfig::for_quinn()` → `quinn::Endpoint::server()`, etc.).
- **No TLS config.** The endpoint does not depend on `alknet-tls`. TLS
configs are built by the assembly layer; the endpoint receives the
resulting transport endpoints.
- **No protocol logic.** The endpoint dispatches by ALPN; the handler
runs the protocol. The endpoint does not know what `alknet/call` or
`alknet/channels` means — it looks up the ALPN in the registry and
spawns the handler.
### Feature gates
```toml
[features]
default = []
quinn = ["dep:quinn", "alknet-core/quinn"] # with_quinn — quinn accept loop
iroh = ["dep:iroh", "alknet-core/iroh"] # with_iroh — iroh accept loop
tcp = ["dep:tokio-rustls"] # with_tcp_tls — TCP+TLS accept loop
```
The `quinn`/`iroh` features pull the corresponding features on
`alknet-core` (for `Connection::from_quinn` / `from_iroh` — the
constructors stay in core; see ADR-083 §"The `quinn` feature split"). 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.
### Dependencies
```
alknet-endpoint
├── alknet-core (Connection, ProtocolHandler, AuthContext,
│ IdentityProvider, DynamicConfig)
├── quinn (optional — quinn accept loop)
├── iroh (optional — iroh accept loop)
├── tokio-rustls (optional — TCP+TLS accept loop, tcp feature)
├── tokio (spawn, watch, TcpListener)
├── arc-swap (DynamicConfig)
└── tracing (logging)
```
`alknet-endpoint` depends on `alknet-core` (for `Connection`,
`ProtocolHandler`, `AuthContext`, `IdentityProvider`, `DynamicConfig`).
`HandlerRegistry` and `EndpointError` live in `alknet-endpoint` (they
move with the endpoint from core). The endpoint does **not** depend on
`alknet-tls` — it takes pre-built transports, so TLS config
construction stays at the assembly layer.
### Crate dependencies (in the dep graph)
```
alknet-endpoint
└── alknet-core (Connection, ProtocolHandler, AuthContext, IdentityProvider, DynamicConfig)
alknet-hub (uses AlknetEndpoint for inbound)
├── alknet-endpoint (the accept-loop runner)
├── alknet-client (the dial — for outbound worker dials, ADR-089)
├── alknet-channels-call (ChannelsAdapter — registered on the HandlerRegistry)
├── alknet-call (CallAdapter, Dispatcher)
├── alknet-http (HttpAdapter)
└── alknet-core (shared types)
alknet-worker (uses AlknetEndpoint if it accepts inbound)
├── alknet-endpoint (if the worker accepts inbound — a hub-worker)
├── alknet-client (the dial — to reach the hub, ADR-089)
└── alknet-core (shared types)
```
`alknet-call`, `alknet-http`, `alknet-tty`, and other handler crates do
**not** depend on `alknet-endpoint`. They depend on `alknet-core` for
`ProtocolHandler` and `Connection`; the endpoint dispatches to them via
the trait, without a dependency edge. This is the dep-weight win: a
handler crate no longer transitively links quinn, iroh, rcgen, or
rustls-acme.
## Assembly layer integration
A downstream hub uses `alknet-endpoint` like this:
```rust
// 1. Build the HandlerRegistry — register all handlers by ALPN.
let mut registry = HandlerRegistry::new();
registry.register(Arc::new(channels_adapter)); // alknet/channels
registry.register(Arc::new(http_adapter)); // h2, http/1.1
// 2. Build the TlsServerConfig(s) via alknet-tls (assembly layer).
let raw_key_tls = TlsServerConfig::new(&raw_key_identity, &native_alpns).await?;
let x509_tls = TlsServerConfig::new(&x509_identity, &web_alpns).await?;
// 3. Build the transport endpoints from the TLS configs.
let quinn_endpoint = raw_key_tls.for_quinn()?.into_endpoint(listen_addr)?;
let tcp_listener = TcpListener::bind(web_addr).await?;
let tls_acceptor = x509_tls.for_tcp_tls();
// 4. Construct the endpoint with all owned transports.
let endpoint = Arc::new(
AlknetEndpoint::new(registry, dynamic, identity_provider, drain_timeout)
.with_quinn(quinn_endpoint)
.with_tcp_tls(tcp_listener, tls_acceptor),
);
// 5. Run — all accept loops run inside run(), shutdown() stops them all.
endpoint.clone().run().await;
```
The endpoint takes the pre-built transports; the assembly layer built
them from `alknet-tls`'s `TlsServerConfig`s. The endpoint does not see
`alknet-tls` — it sees `quinn::Endpoint` and `TlsAcceptor`.
## What `alknet-core` looks like after the extraction
Core loses the endpoint module (~1600 LOC) and 5 heavy deps (`quinn`,
`iroh`, `rcgen`, `rustls-pemfile`, `rustls-acme`). The remaining surface
is the lightweight types+auth+config+ownership+store+fingerprint crate.
See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md)
§"Amendment 2026-07-15 — crate extraction" §"What `alknet-core` looks
like after" for the module-level table and the `quinn` feature split
(`Connection::from_quinn` stays in core; the accept loop moves here).
## Design Decisions
All design decisions are documented as ADRs in
[decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [083](../../decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as multi-transport accept-loop runner + crate extraction | `AlknetEndpoint` takes no TLS config; `with_quinn`/`with_iroh`/`with_tcp_tls` builder methods; public `dispatch` for SSH/WT; extracted from `alknet-core` into `alknet-endpoint` (Amendment 2026-07-15) |
| [086](../../decisions/086-endpoint-types-and-entry-points.md) | Endpoint types and entry points | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type |
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | `HandlerRegistry`, accept loop, static registration (amended by ADR-083) |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-60** (resolved): Where does transport construction live? The
TCP+TLS accept loop lives in `alknet-endpoint` behind a `tcp` feature
as an owned transport. Builder functions are inlined by the assembly
layer. See [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md).
- **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
- [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) —
the decision this spec implements (including the Amendment
2026-07-15 crate extraction)
- [ADR-082](../../decisions/082-alknet-tls-extraction.md) —
`TlsServerConfig` (the TLS config the assembly layer builds; the
endpoint does not see it)
- [ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) —
endpoint types (web/native/iroh); entry-point vs. endpoint ALPN
- [ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) —
`alknet-client` (the client-side analogue — symmetric extraction)
- [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md)
— `Connection::from_stream` / `from_bidi` (the TCP+TLS path)
- [ADR-070](../../decisions/070-bidistreamsource-trait.md) —
`BidiStreamSource` (the `Connection` extension point)
- [`crates/core/endpoint.md`](../core/endpoint.md) — the endpoint
design (will be updated to reflect the extraction; the endpoint
semantics stay, the location moves)
- [`crates/core/core-types.md`](../core/core-types.md) —
`ProtocolHandler`, `Connection`, `AuthContext` (the shared types the
endpoint imports from core)
- [`crates/client/README.md`](../client/README.md) — `alknet-client`
(the client-side complement)
- [`crates/tls/README.md`](../tls/README.md) — `TlsServerConfig` (the
assembly-layer TLS config that produces the transports the endpoint
takes)
- [`crates/hub/README.md`](../hub/README.md) — the hub (the first
multi-transport consumer of the endpoint)
+5 -2
View File
@@ -657,15 +657,18 @@ alknet-hub (Hub struct deps)
│ from_call, FromCallConfig, AdapterError, ClientError)
├── alknet-http (HttpAdapter — for the registration endpoint and browser access)
├── alknet-core (IdentityProvider, Connection, OperationRegistry,
│ HandlerRegistry, AuthContext)
│ AuthContext)
├── tokio (spawn, time::sleep)
└── tracing (logging)
(assembly-layer deps — used by the hub's composition code, not by
the Hub struct itself)
├── alknet-endpoint (AlknetEndpoint with quinn + iroh + tcp features,
│ │ HandlerRegistry — the accept-loop runner)
├── alknet-client (AlknetClient — outbound worker dials, ADR-089)
├── alknet-tls (TlsServerConfig — builds the raw-key + X.509/ACME
│ configs handed to the endpoint's transports)
└── alknet-core [tcp feature] (with_tcp_tls — the TCP+TLS accept loop)
└── alknet-core [quinn/iroh features] (Connection::from_quinn/from_iroh)
```
`alknet-hub` depends on `alknet-channels-call`, `alknet-call`, and
+30 -20
View File
@@ -364,20 +364,25 @@ endpoint section below ("What `AlknetEndpoint` does after the refactor")
describes the **post-refactor target**, not the current source. The
current `crates/alknet-core/src/endpoint.rs` is the **extraction
source** — `AlknetEndpoint::new(static_config, ...)` builds TLS
internally, the shape ADR-083 replaces. The extraction and refactor
are **sequenced**, not simultaneous:
internally, the shape ADR-083 replaces. The endpoint is extracted into
a new crate `alknet-endpoint` (ADR-083 Amendment 2026-07-15) as part of
this work. The extraction and refactor are **sequenced**, not
simultaneous:
1. **`alknet-tls` first** — build the crate in isolation. `TlsServerConfig`
and `TlsClientConfig` are unit-testable against `TlsIdentity` without
touching the endpoint. This is the greenfield step.
2. **Endpoint refactor second** — `endpoint.rs` is refactored to the
ADR-083 shape (`new(handlers, dynamic, identity_provider, drain_timeout)`
+ `with_quinn` / `with_iroh` / `with_tcp_tls`), importing from
`alknet-tls` instead of building TLS internally. The call sites move
to the assembly layer here.
2. **`alknet-endpoint` second** — build the new endpoint crate fresh
against the ADR-083 shape (`new(handlers, dynamic,
identity_provider, drain_timeout)` + `with_quinn` / `with_iroh` /
`with_tcp_tls`), importing `Connection`/`ProtocolHandler`/`AuthContext`
from `alknet-core` and taking pre-built transports (no TLS config —
the assembly layer builds those via `alknet-tls`). The old
`crates/alknet-core/src/endpoint.rs` is deleted.
3. **Assembly layer last** — the deployment binary (hub/worker) builds
the `TlsServerConfig`(s) and `TlsClientConfig`(s), the transports, and
hands them to `AlknetEndpoint` via the builder methods.
hands them to `AlknetEndpoint` (in `alknet-endpoint`) via the builder
methods.
A compilable intermediate state exists after step 1: `alknet-tls` built
and tested standalone, with `endpoint.rs` still in its old shape. The
@@ -385,11 +390,12 @@ call sites for `TlsServerConfig` / `TlsClientConfig` do not exist until
step 2/3 — an implementer testing step 1 writes tests against the TLS
types directly, not against a wired-up endpoint.
### What `AlknetEndpoint` does after the refactor
### What `AlknetEndpoint` (in `alknet-endpoint`) does after the refactor
`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 multi-transport
the endpoint (extracted into `alknet-endpoint` per ADR-083 Amendment
2026-07-15) 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:
@@ -449,11 +455,12 @@ shutdown is single-owner — the endpoint owns all its accept loops
`alknet-tls` provides `for_tcp_tls() -> TlsAcceptor`. The actual TCP
accept loop (`TcpListener::accept` → `TlsAcceptor::accept` →
`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.
`Connection::from_bidi` → `endpoint.dispatch()`) lives in
`alknet-endpoint` behind a `tcp` feature, as an owned transport on
`AlknetEndpoint` (via `with_tcp_tls(listener, acceptor)` — see ADR-083,
Amendment 2026-07-15). `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.
### Client-side — `TlsClientConfig` (ADR-087)
@@ -654,10 +661,12 @@ alknet-call (client-side verifier — unchanged)
alknet-hub (multi-transport endpoint)
├── alknet-tls (TlsServerConfig — shared across quinn + TCP)
├── alknet-endpoint (AlknetEndpoint with quinn + iroh + tcp features, HandlerRegistry)
├── alknet-client (AlknetClient — outbound worker dials, ADR-089)
├── alknet-channels-call (ChannelClient)
├── alknet-call (CallAdapter, Dispatcher)
├── alknet-http (HttpAdapter)
├── alknet-core (AlknetEndpoint with quinn + iroh + tcp features, HandlerRegistry, Connection)
├── alknet-core (Connection, ProtocolHandler, AuthContext, IdentityProvider)
```
`alknet-tls` depends on `alknet-core` only. No handler crate depends on
@@ -694,8 +703,8 @@ See [open-questions.md](../../open-questions.md) for full details.
`rustls::sign` usage is a test helper. `alknet-tls` re-exports the
fingerprint functions for convenience.
- **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
TCP+TLS accept loop lives in `alknet-endpoint` 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
@@ -774,8 +783,9 @@ named by ADR-089; its wire protocol is deferred (OQ-66).
- `docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md`
— `TlsClientConfig` (client-side); not blocked on the dial seam;
breaks the circular hedge; hub-as-client requirement
- `docs/architecture/crates/core/endpoint.md` — current endpoint design
(TLS section will be amended to point to `alknet-tls`)
- `docs/architecture/crates/endpoint/README.md` — `AlknetEndpoint`
(the endpoint spec; TLS config is built by `alknet-tls`, not the
endpoint — per ADR-083)
- `docs/architecture/crates/core/config.md` — `TlsIdentity`, `StaticConfig`
- `crates/alknet-core/src/endpoint.rs` — the server-side code being
extracted (`build_rustls_server_config`, `TlsSetup`, `RawKeyCertResolver`,