The endpoint refactor findings doc relegated TCP+TLS to a 'sibling accept loop' that duplicated the endpoint's dispatch logic at the assembly layer. That was ADR-010 Amendment 1's workaround for the endpoint being welded to quinn — not a deliberate design. The endpoint now exposes a public dispatch() method so every transport (quinn, iroh, TCP+TLS, future SSH, future WebTransport) calls the same dispatch path: handler lookup, build_auth_context, spawn. The transport-specific parts (ALPN extraction, fingerprint extraction, Connection construction, no-handler close) happen before dispatch is called. No duplicated logic at the assembly layer. Also: the acme-tls/1 guard is quinn-specific (ACME challenges arrive over QUIC); a TCP+TLS listener serving HTTPS does not advertise acme-tls/1. build_auth_context becomes a private helper called by dispatch, not a standalone function.
584 lines
27 KiB
Markdown
584 lines
27 KiB
Markdown
# alknet-endpoint-refactor — Endpoint as Pure ALPN Dispatcher
|
|
|
|
**Status:** Findings complete. The endpoint refactor and the TLS
|
|
extraction are interlocked; this document captures the full picture so
|
|
neither gets done in isolation and drops half the context.
|
|
**Date:** 2026-07-13
|
|
**Scope:** `AlknetEndpoint` becomes a pure accept-loop runner (no
|
|
transport construction); `alknet-tls` extraction (`TlsServerConfig`)
|
|
provides the shared cert config the assembly layer needs to build
|
|
transports. Both changes are enabled by the pre-implementation window
|
|
— nobody is using this code yet, and the issues surfaced from trying to
|
|
write the first real consumers (hub, workers, HTTP).
|
|
|
|
---
|
|
|
|
## TL;DR
|
|
|
|
1. **`AlknetEndpoint` conflates two concerns:** transport construction
|
|
(building `quinn::Endpoint`, `iroh::Endpoint`, wiring TLS) and
|
|
dispatch (take a connection, extract ALPN, look up handler, build
|
|
`AuthContext`, spawn). The TLS extraction forces a separation
|
|
because the TLS config has to be built once and shared across
|
|
transports — the endpoint can't build it internally anymore.
|
|
|
|
2. **The endpoint becomes a pure accept-loop runner with a public
|
|
dispatch method.** It owns the transport endpoints it runs accept
|
|
loops for (quinn, iroh), and it exposes `dispatch()` for transport
|
|
accept loops it doesn't own (TCP+TLS, future SSH, future
|
|
WebTransport). Every transport calls the same dispatch path — no
|
|
duplicated `build_auth_context` or handler-lookup logic at the
|
|
assembly layer. The `StaticConfig` parameter, `tls_identity`
|
|
reading, and transport construction all move to the assembly layer.
|
|
|
|
3. **TCP+TLS is a first-class transport, not a sibling afterthought.**
|
|
ADR-010 Amendment 1 made TCP+TLS a "sibling" because the endpoint's
|
|
dispatch logic was crate-private and couldn't be shared. With a
|
|
public `dispatch()` method, the TCP+TLS accept loop calls into the
|
|
endpoint — same path as quinn and iroh. The hub needs TCP+TLS as a
|
|
day-1 requirement (HTTPS for browsers, raw-key fallback for native
|
|
clients when UDP is blocked); it is the most common transport after
|
|
raw-key QUIC, not an edge case.
|
|
|
|
4. **A hub holds one or two `TlsServerConfig`s, not one.** A hub
|
|
serving both browsers and native workers holds a raw-key config
|
|
(QUIC + TCP+TLS fallback for native clients) and an X.509/ACME
|
|
config (TCP+TLS for HTTPS, QUIC for WebTransport when it revives).
|
|
The cert-reuse value is *within* each identity: one config per
|
|
identity, shared across that identity's transports. Iroh and SSH
|
|
share the *key* with the raw-key TLS path, not the `TlsServerConfig`.
|
|
|
|
5. **The TCP fallback for native clients doesn't need X.509.** The
|
|
raw-key `TlsServerConfig` serves both QUIC and TCP+TLS. X.509 is
|
|
only for the browser/HTTPS path. Raw-key and X.509 clients can
|
|
connect to either server type — the server cert type and client cert
|
|
type are independent in TLS. `AcceptAnyCertVerifier` already accepts
|
|
both.
|
|
|
|
6. **This is a one-way door (endpoint struct shape) done during the
|
|
pre-implementation window.** The cost of doing it now is the same
|
|
refactor that has to happen anyway. The cost of not doing it is a
|
|
hybrid model (quinn TLS injected, iroh key read internally, TCP+TLS
|
|
outside with duplicated dispatch) that will confuse every future
|
|
reader and block the hub crate (the first multi-transport consumer).
|
|
|
|
---
|
|
|
|
## 1. The transport landscape
|
|
|
|
### 1.1 The full transport matrix
|
|
|
|
| Transport | TLS identity | Serves | Who connects | Status |
|
|
|-----------|-------------|--------|---------------|--------|
|
|
| QUIC (raw key) | RFC 7250 Ed25519 | `alknet/channels`, `alknet/call` | Native clients (workers, spokes) | Day 1 |
|
|
| TCP+TLS (raw key fallback) | RFC 7250 Ed25519 | Same as above | Native clients (UDP blocked) | Day 1 |
|
|
| TCP+TLS (X.509 / ACME) | X.509 | `h2`/`http/1.1` (HTTPS) | Browsers, HTTP clients | Day 1 |
|
|
| QUIC (X.509 / ACME) | X.509 | WebTransport | Browsers | Deferred (ADR-044; revives with channels) |
|
|
| iroh | Ed25519 (own TLS) | `alknet/channels`, `alknet/call` | P2P native clients | Day 1 |
|
|
| TCP (SSH) | Ed25519 key (SSH) | `alknet/ssh` channels | Legacy SSH clients | Future |
|
|
|
|
### 1.2 Two cert identities, shared within each
|
|
|
|
A hub serving both browsers and native workers holds **two**
|
|
`TlsServerConfig`s:
|
|
|
|
- **Raw key config** — built from `TlsIdentity::RawKey(Ed25519SecretKey)`.
|
|
Shared across:
|
|
- QUIC listener (`for_quinn()`) — native clients with UDP
|
|
- TCP+TLS listener (`for_tcp_tls()`) — native clients when UDP blocked
|
|
- The same `Ed25519SecretKey` also feeds iroh and (future) SSH
|
|
|
|
- **X.509 config** — built from `TlsIdentity::X509` or `TlsIdentity::Acme`.
|
|
Shared across:
|
|
- TCP+TLS listener (`for_tcp_tls()`) — HTTPS for browsers
|
|
- QUIC listener (`for_quinn()`) — WebTransport when it revives
|
|
|
|
The cert-reuse value is *within* each identity, not across them. A hub
|
|
doesn't share one cert for everything — it shares one cert per identity
|
|
across that identity's transports. This is why `AlknetEndpoint::new`
|
|
taking a single `Arc<TlsServerConfig>` (as ADR-082 currently specifies)
|
|
is wrong for the hub case: the hub has two configs. The endpoint
|
|
shouldn't take any TLS config — the assembly layer builds the transports
|
|
and hands the endpoint pre-built quinn/iroh endpoints.
|
|
|
|
### 1.3 Iroh and SSH share the key, not the TLS config
|
|
|
|
Iroh has its own TLS built into the `Endpoint` (RFC 7250 raw keys). It
|
|
does not consume a `rustls::ServerConfig`. SSH uses the Ed25519 key for
|
|
its own handshake. Both share the `Ed25519SecretKey` with the raw-key
|
|
`TlsServerConfig`, but neither receives a `TlsServerConfig`.
|
|
|
|
The assembly layer reads `Ed25519SecretKey` from `StaticConfig` and
|
|
hands it to:
|
|
- `TlsServerConfig::new(TlsIdentity::RawKey(key), ...)` — for quinn/TCP+TLS
|
|
- `iroh::SecretKey::from_bytes(key.as_bytes())` — for iroh
|
|
- (future) the SSH handler's key configuration
|
|
|
|
### 1.4 Raw-key / X.509 mixing in the TLS handshake
|
|
|
|
A raw-key client can connect to an X.509 server and vice versa. In
|
|
TLS, the server cert type and client cert type are independent. A
|
|
server presenting an X.509 cert can accept a client presenting a raw
|
|
key (RFC 7250), as long as both sides have RFC 7250 support in their
|
|
rustls config. `AcceptAnyCertVerifier` already accepts both, and its
|
|
`supported_verify_schemes()` includes ED25519.
|
|
|
|
The mixing cases:
|
|
- **Raw-key client → X.509 server**: client verifies server via CA
|
|
(ADR-034 public X.509 case) or fingerprint pin (known hub); server
|
|
extracts client's `ed25519:<hex>` fingerprint.
|
|
- **X.509 client → raw-key server**: client verifies server via
|
|
fingerprint pin (raw-key remotes are always known peers); server
|
|
extracts client's `SHA256:<hex>` fingerprint.
|
|
|
|
This means the TCP fallback for native clients doesn't need X.509 — the
|
|
raw-key config serves both QUIC and TCP+TLS. X.509 is only for the
|
|
browser/HTTPS path.
|
|
|
|
### 1.5 What's required vs. what's built now
|
|
|
|
All transports in the matrix above are **requirements** — not "nice to
|
|
have." But we do not have to build all of them right now. The
|
|
abstractions (the endpoint refactor, the `TlsServerConfig` extraction,
|
|
`Connection::from_stream`/`from_bidi`) are what make the full matrix
|
|
tractable without blocking any of the future transports. The point is
|
|
to get the abstractions right so that each transport can be added
|
|
without changing the core.
|
|
|
|
By volume, the most common use case is raw-key QUIC (native P2P,
|
|
workers). But we have to support the HTTP crate and X.509 from day one.
|
|
The TCP fallback for native clients (raw key) is important too since
|
|
UDP is blocked on some networks. A worker has to register with the hub
|
|
via HTTP POST in many cases, then connect via QUIC if available and
|
|
fall back to TCP if not.
|
|
|
|
---
|
|
|
|
## 2. The endpoint refactor
|
|
|
|
### 2.1 Current state: the endpoint conflates construction and orchestration
|
|
|
|
`AlknetEndpoint::new` currently does three things:
|
|
|
|
1. **Builds the quinn endpoint** — reads `static_config.tls_identity`,
|
|
constructs TLS internally (`TlsSetup::new`,
|
|
`build_rustls_server_config`), wraps it into
|
|
`quinn::ServerConfig`, binds `quinn::Endpoint::server()`.
|
|
2. **Builds the iroh endpoint** — reads `static_config.tls_identity`
|
|
again (for the Ed25519 key), calls `build_iroh_endpoint()`.
|
|
3. **Runs accept loops** for whichever of those it built, dispatching
|
|
by ALPN through the `HandlerRegistry`.
|
|
|
|
TCP+TLS is not in this picture at all. ADR-010 Amendment 1 made it a
|
|
"sibling accept loop" that runs outside the endpoint — the assembly
|
|
layer builds it and dispatches through the same `HandlerRegistry`. But
|
|
the dispatch logic (`build_auth_context`, fingerprint extraction, the
|
|
`acme-tls/1` guard, handler lookup + spawn) is crate-private
|
|
(`dispatch_quinn` / `dispatch_iroh` in `endpoint.rs`), so the
|
|
assembly layer can't call it. A TCP+TLS accept loop has to duplicate it
|
|
or call `HandlerRegistry::get` and `build_auth_context` directly (the
|
|
latter is also crate-private). The "sibling" framing was a workaround
|
|
for the endpoint being welded to quinn — not a deliberate design.
|
|
|
|
The result is a three-tier asymmetry: quinn is built and dispatched
|
|
internally, iroh is built internally and dispatched internally, TCP+TLS
|
|
is built outside and dispatched outside (with duplicated logic). The
|
|
TLS extraction surfaces this because it pulls quinn's TLS construction
|
|
out, leaving iroh's key reading and TCP+TLS's second-class status as
|
|
visible inconsistencies.
|
|
|
|
### 2.2 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 the `AuthContext`, spawn the handler. This is the same for every
|
|
transport. The transport-specific parts are:
|
|
|
|
- **ALPN extraction** — quinn: `handshake_data().protocol`; iroh:
|
|
`connecting.alpn().await`; TCP+TLS: `tls_stream.alpn()` (from the
|
|
`tokio-rustls` handshake result).
|
|
- **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: `connection.close()`; iroh:
|
|
`connection.close()`; TCP+TLS: drop the stream.
|
|
|
|
Everything between those transport-specific bookends — the ACME ALPN
|
|
guard, the handler lookup, the `build_auth_context` call, the
|
|
`tokio::spawn` — is identical. If the endpoint exposes a public
|
|
`dispatch` method that takes the already-extracted ALPN, fingerprint,
|
|
and `Connection`, every transport's accept loop calls the same dispatch
|
|
path. No duplication.
|
|
|
|
### 2.3 The new endpoint shape
|
|
|
|
The endpoint is a pure accept-loop runner + a public dispatch method.
|
|
It owns the transport endpoints it runs accept loops for (quinn, iroh),
|
|
and it exposes `dispatch` for transport accept loops that it doesn't
|
|
own (TCP+TLS, future SSH, future WebTransport).
|
|
|
|
```rust
|
|
pub struct AlknetEndpoint {
|
|
quinn: Option<quinn::Endpoint>,
|
|
iroh: Option<iroh::Endpoint>,
|
|
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;
|
|
|
|
/// Dispatch a pre-established connection by ALPN. Called by the
|
|
/// endpoint's own accept loops (quinn, iroh) after transport-
|
|
/// specific extraction, and by external accept loops (TCP+TLS,
|
|
/// future SSH, future WebTransport) after their own extraction.
|
|
/// The caller supplies the ALPN, the fingerprint (if available),
|
|
/// the remote address (if available), and the `Connection`.
|
|
pub fn dispatch(
|
|
&self,
|
|
connection: Connection,
|
|
alpn: Vec<u8>,
|
|
fingerprint: Option<String>,
|
|
remote_addr: Option<SocketAddr>,
|
|
);
|
|
|
|
/// Run the endpoint's accept loops. Returns when shutdown is
|
|
/// signaled. The caller drives shutdown via `shutdown_sender()`.
|
|
pub async fn run(self: Arc<Self>);
|
|
}
|
|
```
|
|
|
|
The `StaticConfig` parameter disappears from `new()`. The assembly
|
|
layer reads `StaticConfig`, builds the transports, and hands the
|
|
quinn/iroh endpoints to `AlknetEndpoint` via builder methods.
|
|
`build_iroh_endpoint` and `build_quinn_server_config_from_rustls` move
|
|
out of core — the assembly layer (or `alknet-tls` as a convenience)
|
|
builds the transport endpoints.
|
|
|
|
TCP+TLS accept loops call `endpoint.dispatch(connection, alpn,
|
|
fingerprint, remote_addr)` — the same dispatch path quinn and iroh
|
|
use. No duplicated `build_auth_context` or handler-lookup logic at the
|
|
assembly layer. The hub's TCP+TLS accept loop is still a sibling (it
|
|
owns its `TcpListener` and `TlsAcceptor`), but its dispatch is
|
|
first-class — it calls into the endpoint, not around it.
|
|
|
|
This resolves the ADR-010 Amendment 1 workaround. Amendment 1 made
|
|
TCP+TLS a sibling because the endpoint couldn't share its dispatch
|
|
logic. Now it can — `dispatch` is public. The sibling framing (the
|
|
accept loop lives outside the endpoint struct) survives; the
|
|
second-class dispatch (duplicated logic) doesn't.
|
|
|
|
### 2.4 What the assembly layer does
|
|
|
|
For a hub serving native clients (raw key) and browsers (X.509):
|
|
|
|
1. Read `StaticConfig`.
|
|
2. Build the raw-key `TlsServerConfig` from `TlsIdentity::RawKey(key)`.
|
|
3. Build the X.509 `TlsServerConfig` from `TlsIdentity::Acme { ... }`
|
|
(or `X509`).
|
|
4. Build the quinn endpoint for native clients:
|
|
`raw_key_tls.for_quinn()` → `quinn::Endpoint::server()`.
|
|
5. Build the iroh endpoint: `iroh::Endpoint::builder().secret_key(...)`.
|
|
6. Construct `AlknetEndpoint::new(registry, ...)` with `.with_quinn()`
|
|
and `.with_iroh()`.
|
|
7. Build the TCP+TLS accept loop for native fallback:
|
|
`raw_key_tls.for_tcp_tls()` → `TcpListener` + `TlsAcceptor` →
|
|
`endpoint.dispatch(from_bidi(tls_stream, alpn, addr), alpn, fp, addr)`.
|
|
8. Build the TCP+TLS accept loop for HTTPS:
|
|
`x509_tls.for_tcp_tls()` → `TcpListener` + `TlsAcceptor` →
|
|
`endpoint.dispatch(...)`.
|
|
9. (Future) Build the quinn endpoint for WebTransport:
|
|
`x509_tls.for_quinn()` → `quinn::Endpoint::server()`.
|
|
10. Spawn the TCP+TLS accept loops (they call `endpoint.dispatch`).
|
|
11. Spawn `endpoint.run()` — runs the quinn/iroh accept loops.
|
|
|
|
For a bare-bones P2P node (no public IP, no ACME, no browsers): just
|
|
step 1, step 5, step 6 (with only `.with_iroh()`), step 11. No
|
|
`TlsServerConfig` needed at all.
|
|
|
|
### 2.5 What this resolves
|
|
|
|
- **C5 (iroh path ownership):** the endpoint doesn't build iroh; the
|
|
assembly layer does. No ambiguity.
|
|
- **TCP+TLS second-class dispatch:** `dispatch` is public; TCP+TLS
|
|
accept loops call the same path as quinn/iroh. No duplicated
|
|
`build_auth_context` or handler-lookup logic. ADR-010 Amendment 1's
|
|
workaround is retired.
|
|
- **The single-`Arc<TlsServerConfig>` problem:** ADR-082 currently
|
|
specifies `AlknetEndpoint::new` taking `Arc<TlsServerConfig>`, but a
|
|
hub has two configs (raw key + X.509). With the endpoint taking no
|
|
TLS config, this disappears.
|
|
- **The `static_config.tls_identity` reading:** the endpoint no longer
|
|
reads it. The assembly layer reads it and builds the transports.
|
|
- **The `acme_state_handle` on the endpoint:** the ACME task handle
|
|
lives on `TlsServerConfig` (the assembly layer owns it), not on the
|
|
endpoint.
|
|
|
|
---
|
|
|
|
## 3. Interaction with `alknet-tls` extraction (ADR-082)
|
|
|
|
### 3.1 The TLS extraction is the trigger, not the whole refactor
|
|
|
|
The TLS extraction (`alknet-tls` crate with `TlsServerConfig`) is what
|
|
surfaced the endpoint's conflation. But the endpoint refactor is a
|
|
separate, larger change that ADR-082 should reference but not contain.
|
|
|
|
### 3.2 ADR-082 changes
|
|
|
|
With the endpoint refactor, ADR-082's `AlknetEndpoint::new` signature
|
|
changes. Instead of:
|
|
|
|
```rust
|
|
pub async fn new(
|
|
static_config: &StaticConfig,
|
|
tls_config: Arc<TlsServerConfig>, // ← was the new param
|
|
handlers: HandlerRegistry,
|
|
dynamic: Arc<ArcSwap<DynamicConfig>>,
|
|
identity_provider: Arc<dyn IdentityProvider>,
|
|
) -> Result<Self, EndpointError>;
|
|
```
|
|
|
|
The endpoint takes no TLS config at all (see §2.3). ADR-082 should
|
|
reference the endpoint refactor ADR (to be written) for the new
|
|
signature, and focus on what `alknet-tls` provides:
|
|
`TlsServerConfig` and its accessors.
|
|
|
|
### 3.3 What moves out of `alknet-core/endpoint.rs`
|
|
|
|
| Code | Destination |
|
|
|------|-------------|
|
|
| `build_rustls_server_config()` | `alknet-tls` |
|
|
| `TlsSetup` / ACME state machine | `alknet-tls` (`TlsServerConfig::new` ACME path) |
|
|
| `RawKeyCertResolver` | `alknet-tls` |
|
|
| `Ed25519SigningKey` | `alknet-tls` |
|
|
| `AcceptAnyCertVerifier` | `alknet-tls` |
|
|
| `SelfSignedCert` / `generate_self_signed_cert()` | `alknet-tls` |
|
|
| `load_cert_chain()` / `load_private_key()` | `alknet-tls` |
|
|
| `build_quinn_server_config_from_rustls()` | `alknet-tls` (`for_quinn()`) |
|
|
| `build_iroh_endpoint()` | Assembly layer (or a helper; construction decision is assembly-layer) |
|
|
|
|
What stays in `alknet-core/endpoint.rs`:
|
|
- `AlknetEndpoint` struct (accept-loop runner + public `dispatch`)
|
|
- `HandlerRegistry`
|
|
- `dispatch` (public — takes ALPN, fingerprint, `Connection`; calls
|
|
`build_auth_context`, looks up handler, spawns)
|
|
- `dispatch_quinn` / `dispatch_iroh` (private — do transport-specific
|
|
extraction, then call `dispatch`)
|
|
- `run_quinn_accept_loop` / `run_iroh_accept_loop`
|
|
- `extract_quinn_alpn` / `extract_quinn_client_fingerprint` /
|
|
`extract_iroh_client_fingerprint`
|
|
- The `acme-tls/1` early-return guard (inside `dispatch_quinn`; the
|
|
guard is quinn-specific — ACME challenges arrive over QUIC. TCP+TLS
|
|
has no ACME ALPN guard because `acme-tls/1` is only negotiated over
|
|
QUIC for the TLS-ALPN-01 challenge. A TCP+TLS listener serving HTTPS
|
|
does not advertise `acme-tls/1`.)
|
|
|
|
`build_auth_context` becomes a private helper called by `dispatch`,
|
|
not a standalone function — it's the shared core of the dispatch path
|
|
and should not be called directly by the assembly layer. The assembly
|
|
layer calls `dispatch`, which calls `build_auth_context` internally.
|
|
|
|
What stays in `alknet-core/config.rs`:
|
|
- `TlsIdentity` enum (config type)
|
|
- `Ed25519SecretKey` (config type)
|
|
- `AcmeDirectory` (config type)
|
|
- `StaticConfig` (config type)
|
|
|
|
What stays in `alknet-core/fingerprint.rs` (pending OQ-59):
|
|
- `fingerprint_from_cert_der`, `extract_ed25519_raw_key_from_spki`
|
|
|
|
### 3.4 Ordering question: tls first or core refactor first?
|
|
|
|
This is deferred to the task-decomposition phase. Both are
|
|
interlocked: the TLS extraction removes code from `endpoint.rs`, and the
|
|
endpoint refactor changes what stays. The natural ordering might be:
|
|
|
|
1. **Endpoint refactor first** — change `AlknetEndpoint` to a pure
|
|
accept-loop runner, move `build_iroh_endpoint` and
|
|
`build_quinn_server_config_from_rustls` out of core. At this point,
|
|
the TLS setup code still exists in core but is called by the
|
|
assembly layer instead of the endpoint.
|
|
2. **TLS extraction second** — move the TLS setup code into
|
|
`alknet-tls`, build `TlsServerConfig`, replace the assembly layer's
|
|
direct calls with `TlsServerConfig::new` / `for_quinn()` /
|
|
`for_tcp_tls()`.
|
|
|
|
But this is a task-decomposition decision, not an architecture
|
|
decision. The architecture is: both changes happen, the endpoint
|
|
becomes a pure accept-loop runner, and `alknet-tls` provides the
|
|
shareable cert config.
|
|
|
|
---
|
|
|
|
## 4. The remaining review items (from the tls spec review)
|
|
|
|
These are unaffected by the endpoint refactor. They're about the TLS
|
|
crate's API surface and the OQ/ADR status.
|
|
|
|
### 4.1 C2: Define `TlsError` type
|
|
|
|
The `TlsError` type is used in every public signature but never
|
|
defined. Needs a variant list. The current code uses
|
|
`EndpointError::TlsConfig(io::Error)`. The new type should cover:
|
|
- IO errors (file read failures for cert/key loading)
|
|
- rustls config errors (protocol version, cipher suite setup)
|
|
- Quinn config errors (`QuicServerConfig::try_from` failure)
|
|
- ACME feature-not-enabled (when `TlsIdentity::Acme` is configured
|
|
without the `acme` feature)
|
|
|
|
### 4.2 C3 + C6: ACME task lifecycle and cert renewal
|
|
|
|
The ACME state machine task is spawned by `TlsServerConfig::new` (ACME
|
|
path) and its `JoinHandle` is held on `TlsServerConfig`. The spec
|
|
currently says "dropping this handle does NOT stop the ACME state
|
|
machine" but never says what *does* stop it.
|
|
|
|
Cert renewal behavior (confirmed from the reverse-proxy reference):
|
|
|
|
| Identity | Renewal behavior | Drain needed? |
|
|
|----------|-----------------|---------------|
|
|
| ACME | Live — `ResolvesServerCertAcme` swaps cert in place; all listeners sharing the `Arc<rustls::ServerConfig>` pick up the renewed cert on the next TLS handshake | No |
|
|
| X.509 (manual) | Loaded once at startup via `with_single_cert`; no live renewal | Yes (restart to load new cert) |
|
|
| RawKey | No expiry — Ed25519 keys don't expire | N/A |
|
|
| SelfSigned | Dev only — restart to regenerate | N/A |
|
|
|
|
The ACME renewal mechanism: `TlsServerConfig::new` (ACME path) calls
|
|
`state.resolver()` to get an `Arc<ResolvesServerCertAcme>`, wires it
|
|
into the `rustls::ServerConfig` via `with_cert_resolver(resolver)`, and
|
|
spawns the `AcmeState` event loop. When the event loop processes a
|
|
renewed cert (`EventOk::DeployedNewCert`), it updates the resolver's
|
|
internal cert in place. Since `for_quinn()` and `for_tcp_tls()` clone
|
|
the `rustls::ServerConfig` (which shares the `Arc<ResolvesServerCertAcme>`),
|
|
both the quinn endpoint and the TCP+TLS acceptor see the renewed cert
|
|
on the next handshake. No drain, no restart, no listener replacement.
|
|
|
|
The shutdown path: the ACME task's lifetime is the renewal lifetime.
|
|
Shutting it down stops renewal (but existing listeners keep serving
|
|
the last-known cert until it expires). Restarting the process
|
|
re-spawns the state machine, which deploys the cached cert
|
|
(`DeployedCachedCert`) and resumes renewal. The `TlsServerConfig`
|
|
needs a `shutdown()` method (or a `Drop` impl that aborts the handle)
|
|
so the assembly layer can cleanly stop the ACME task on process
|
|
shutdown.
|
|
|
|
### 4.3 C4: OQ-59 (fingerprint.rs location)
|
|
|
|
OQ-59 is open but the spec treats `fingerprint.rs` staying in core as
|
|
decided. The "Likely resolution" in OQ-59 is Option A (stay in core),
|
|
with solid rationale: `alknet-call`'s client-side
|
|
`FingerprintPinVerifier` uses fingerprint functions and shouldn't
|
|
have to depend on `alknet-tls` (which would pull `rustls` + `tokio` +
|
|
optionally `quinn`/`tokio-rustls`/`rustls-acme` into every
|
|
client-only deployment). Recommendation: resolve OQ-59 to Option A
|
|
(stay in core) — it's a two-way door (moving a module later is a
|
|
refactor, not a wire-format change), and the rationale is clear.
|
|
|
|
### 4.4 W3: Feature-gate edge cases
|
|
|
|
- `TlsIdentity::Acme` configured without the `acme` feature — should
|
|
return a runtime error (matching the current code's behavior).
|
|
- `default = []` with no features — `new()` and `rustls_config()` work
|
|
but `for_quinn`/`for_tcp_tls` don't exist. This is a supported config
|
|
for iroh-only deployments (no `TlsServerConfig` needed at all — the
|
|
assembly layer just uses the `Ed25519SecretKey` directly).
|
|
- `acme` without `quinn` or `tcp` — ACME task spawns, `rustls_config()`
|
|
available, but no transport accessor. Valid for a pure cert-provider
|
|
use case (not currently expected, but not blocked).
|
|
|
|
### 4.5 W4: aws_lc_rs crypto provider ADR
|
|
|
|
The `aws_lc_rs::default_provider()` choice is a load-bearing decision
|
|
(FIPS status, platform support) with no ADR. Either add a clause to
|
|
ADR-082 (or ADR-027) recording the choice, or write a small standalone
|
|
ADR. The invariant "do not switch to `ring` or the process-default
|
|
provider without an ADR" is already in the spec; the decision record
|
|
should exist.
|
|
|
|
### 4.6 W5: ADR-082 status
|
|
|
|
ADR-082 is "Proposed." If implementation is about to begin (and the
|
|
endpoint refactor is part of the same work), the ADR should move to
|
|
"Accepted" — with OQ-59 noted as a non-blocking open question that
|
|
can be resolved alongside implementation. "Proposed" for an
|
|
actively-implemented extraction is a stale status.
|
|
|
|
---
|
|
|
|
## 5. The reverse-proxy as reference
|
|
|
|
The reverse-proxy (`/workspace/@alkdev/reverse-proxy/`) is a useful
|
|
reference for the TLS setup, with the caveat that it is X.509-only
|
|
(manual + ACME). The relevant code:
|
|
|
|
- `src/tls/acme.rs` — `AcmeTlsConfig::setup()` constructs the
|
|
`AcmeState` and gets the `resolver()`. `spawn_acme_state()` runs the
|
|
event loop with a `tokio::select!` against a shutdown signal (so the
|
|
ACME task can be cleanly stopped, not just aborted).
|
|
- `src/tls/acceptor.rs` — `TlsMode` enum (`Manual` vs `Acme`) holds
|
|
the `Arc<ServerConfig>` and the `Arc<ResolvesServerCertAcme>`.
|
|
`setup_tls()` builds the `TlsAcceptor` from the server config.
|
|
- `src/tls/config.rs` — `crypto_provider()` constructs the
|
|
`aws_lc_rs` provider with restricted cipher suites. Manual cert
|
|
loading via `rustls-pemfile`.
|
|
|
|
What the reverse-proxy does that alknet-tls should learn from:
|
|
- The ACME task is spawned with a shutdown signal (`tokio::select!`
|
|
against `shutdown_rx.changed()`), not just a bare `JoinHandle`. This
|
|
is cleaner than abort — the state machine gets to finish its current
|
|
operation and exit gracefully. (Relevant to C3.)
|
|
- The `TlsMode` enum separates the static config from the runtime
|
|
objects — `Manual(Arc<ServerConfig>)` vs
|
|
`Acme { default_config, resolver }`. This mirrors the
|
|
`TlsServerConfig` / `TlsIdentity` split.
|
|
- The `ResolvesServerCertAcme` is the live renewal mechanism — the
|
|
`TlsAcceptor` built from `default_config` (which holds an `Arc` to
|
|
the resolver) picks up renewed certs on the next handshake. No
|
|
drain, no restart. (Relevant to C6.)
|
|
|
|
What the reverse-proxy doesn't cover (because it's X.509-only):
|
|
- RFC 7250 raw keys (the `RawKeyCertResolver` / `Ed25519SigningKey`
|
|
path)
|
|
- Multiple transport sharing (it's TCP-only; no quinn, no iroh)
|
|
- The endpoint-as-accept-loop-runner pattern (it has its own
|
|
`serve_https_listener` that's simpler than `AlknetEndpoint`)
|
|
|
|
---
|
|
|
|
## 6. Open questions for the next session
|
|
|
|
1. **C2 (`TlsError` variants):** what error types should `TlsError`
|
|
wrap? The current `EndpointError::TlsConfig(io::Error)` is too
|
|
narrow — it doesn't cover `rustls::Error` or ACME-specific errors.
|
|
Need a variant list.
|
|
|
|
2. **C3 (ACME shutdown):** should `TlsServerConfig` have a
|
|
`shutdown()` method (clean, explicit) or a `Drop` impl (automatic,
|
|
but surprising)? The reverse-proxy uses a shutdown signal
|
|
(`tokio::select!` against `shutdown_rx.changed()`) rather than
|
|
`abort()`. Should alknet-tls do the same?
|
|
|
|
3. **C4 (OQ-59):** resolve to Option A (stay in core) or keep open?
|
|
Recommendation: resolve to Option A.
|
|
|
|
4. **Endpoint refactor ADR:** draft ADR-083 (amending ADR-010) for the
|
|
endpoint-as-accept-loop-runner shape, or fold it into ADR-082?
|
|
Recommendation: standalone ADR — it changes ADR-010's endpoint
|
|
design, which is a broader change than the TLS extraction.
|
|
|
|
5. **Ordering:** does the endpoint refactor land before the TLS
|
|
extraction, or after? Deferred to task decomposition. |