Phase 3 of the crate extraction (per findings.md): create the alknet-client crate — the native client dial seam, client-side analogue of AlknetEndpoint. Three dial methods (dial_quic, dial_tcp_tls, dial_iroh) unified on &ConnectionCredentials (ADR-091), pre-built transports via builder methods, optional SOCKS5 proxy support (ADR-090). 9 tasks, 7 generations, no cycles: - client/crate-init: Cargo.toml, feature flags, module skeleton - client/error-type: ClientDialError enum (5 variants) - client/client-core: AlknetClient struct + builder methods - client/dial-quic: QUIC dial via quinn - client/dial-tcp-tls: TCP+TLS dial via tokio-rustls - client/dial-iroh: Iroh dial (key-not-config) - client/socks5-proxy: Socks5ProxyConfig, Socks5UdpSocket, proxy integration - client/tests: Unit tests + integration test - client/review-client: Review checkpoint Depends on: tls/review-tls, endpoint/review-endpoint (both completed). Purely additive — old CallClient::connect stays until Phase 5 prune.
8.5 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| client/dial-quic | Implement dial_quic — QUIC dial via quinn, producing a Connection | pending |
|
moderate | medium | component | implementation |
Description
Phase 3, Task 4. Implement AlknetClient::dial_quic in crates/alknet-client/src/dial/quinn.rs.
The QUIC dial: builds a TlsClientConfig from ConnectionCredentials, constructs a
quinn::ClientConfig, dials addr on alpn, and returns a Connection via
Connection::from_quinn_with_alpn.
This is a fresh build against the ADR-089/091 shape, not a copy of the old
CallClient::connect. The old connect() hardcoded alknet/call ALPN and returned a
CallConnection (welding the dial to the call protocol). The new dial_quic takes the
ALPN as a parameter and returns a Connection — the protocol take-over is the caller's
concern.
Target shape (per architecture spec)
impl AlknetClient {
/// QUIC dial. Builds a `TlsClientConfig` from `creds`
/// (ADR-034 verifier selection + ADR-084 provider), dials `addr`
/// on `alpn`, returns a `Connection` via
/// `Connection::from_quinn_with_alpn`. The `server_name` is the
/// TLS SNI / name (for X.509; ignored for raw-key pinning).
/// Feature-gated on `quinn`.
#[cfg(feature = "quinn")]
pub async fn dial_quic(
&self,
addr: SocketAddr,
server_name: &str,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError>;
}
Implementation outline
#[cfg(feature = "quinn")]
pub async fn dial_quic(
&self,
addr: SocketAddr,
server_name: &str,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError> {
// 1. Build TlsClientConfig from credentials + ALPN
let tls_config = TlsClientConfig::new(creds, alpn)?; // TlsError → ClientDialError::TlsConfig via #[from]
// 2. Convert to quinn::ClientConfig
let client_config = tls_config.for_quinn()?;
// 3. Build or use the quinn endpoint
let endpoint = match &self.quinn {
Some(ep) => ep.clone(),
None => return Err(ClientDialError::NoTransport { transport: "quinn" }),
};
// 4. Connect
let conn = endpoint
.connect_with(client_config, addr, server_name)
.map_err(|e| ClientDialError::Connect(e.to_string()))?
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
// 5. Wrap as Connection
Ok(Connection::from_quinn_with_alpn(conn, alpn.to_vec()))
}
Key design decisions
-
TlsClientConfig::new(creds, alpn): The TLS config is built fromConnectionCredentials(ADR-091) — the unified transport-level credential bundle. TheTlsErrorfrom config construction is converted toClientDialError::TlsConfigvia the#[from]impl. -
server_nameis the TLS SNI: For X.509 endpoints, this is the hostname the server's cert was issued for. For raw-key endpoints, it's ignored by the verifier (fingerprint pin doesn't use SNI). The parameter is always present for caller simplicity — the caller doesn't need to know which verifier path is active. -
alpnis a byte slice: The ALPN protocol identifier (e.g.,b"alknet/call",b"alknet/channels"). The dial is ALPN-agnostic — it dials any ALPN the remote endpoint advertises. -
Returns
Connection, notCallConnection: The oldconnect()returned aCallConnection(welding the dial to the call protocol). The newdial_quicreturns aConnection— the caller hands it toCallClient::spawn_dispatchorChannelClient::from_connection. -
NoTransporterror whenwith_quinnnot set: The dial checks that a quinn endpoint was configured. If not, it returnsClientDialError::NoTransport. -
SOCKS5 proxy path: When
self.socks5isSome, the dial routes through the proxy (UDP ASSOCIATE) instead of using the pre-built quinn endpoint directly. This is implemented in theclient/socks5-proxytask — this task implements the direct (no-proxy) path. The proxy integration point is a conditional branch:let conn = if let Some(proxy) = &self.socks5 { // proxied path (implemented in socks5-proxy task) dial_quic_via_socks5(proxy, addr, server_name, alpn, creds).await? } else { // direct path (this task) endpoint.connect_with(client_config, addr, server_name)?.await? };For this task, the proxy branch can be a
todo!()orunimplemented!()— thesocks5-proxytask fills it in.
Reference: the old connect() (what we're replacing)
The old CallClient::connect (lines 142-168 of call_client.rs):
pub async fn connect(&self, addr: SocketAddr, credentials: CallCredentials)
-> Result<CallConnection, ClientError>
{
let alpn = b"alknet/call".to_vec(); // hardcoded ALPN
let client_config = build_quinn_client_config(&credentials, &alpn)?;
let bind_addr: SocketAddr = "0.0.0.0:0".parse().expect("valid bind addr");
let endpoint = quinn::Endpoint::client(bind_addr)?; // builds endpoint internally
let connection = endpoint.connect_with(client_config, addr, "alknet")?.await?;
let connection = Connection::from_quinn_with_alpn(connection, alpn);
Ok(self.spawn_dispatch(connection)) // welds dial to protocol take-over
}
The new dial_quic differs in every dimension: ALPN is a parameter (not hardcoded),
the endpoint is pre-built (not constructed internally), credentials are
ConnectionCredentials (not CallCredentials), the return type is Connection
(not CallConnection), and the error type is ClientDialError (not ClientError).
What this does NOT include
- The SOCKS5 proxy path — separate task (
client/socks5-proxy) dial_tcp_tls— separate taskdial_iroh— separate task- Tests — separate task
Acceptance Criteria
AlknetClient::dial_quicimplemented incrates/alknet-client/src/dial/quinn.rs- Signature:
pub async fn dial_quic(&self, addr: SocketAddr, server_name: &str, alpn: &[u8], creds: &ConnectionCredentials) -> Result<Connection, ClientDialError> - Feature-gated on
#[cfg(feature = "quinn")] - Builds
TlsClientConfig::new(creds, alpn)—TlsErrorconverts toClientDialError::TlsConfigvia#[from] - Converts to
quinn::ClientConfigviatls_config.for_quinn() - Uses pre-built quinn endpoint from
self.quinn(cloned) - Returns
NoTransporterror whenself.quinnisNone - Connects via
endpoint.connect_with(client_config, addr, server_name) Connecterrors (pre- and post-handshake) map toClientDialError::Connect(String)- Returns
Connection::from_quinn_with_alpn(conn, alpn.to_vec()) - Does NOT call
spawn_dispatch(protocol take-over is caller's concern) - Does NOT hardcode
alknet/callALPN (ALPN is a parameter) - SOCKS5 proxy branch is a
todo!()or conditional on#[cfg(feature = "socks5")](filled in byclient/socks5-proxy) cargo check -p alknet-client --features quinnsucceedscargo clippy -p alknet-client --features quinnsucceeds with no warningscargo build --workspacestill succeeds (old code untouched)
References
- docs/architecture/crates/client/README.md —
dial_quicsection (lines 156-171) - docs/architecture/decisions/089-alknetclient-native-dial-seam.md — ADR-089 §3
- docs/architecture/decisions/091-connectioncredentials-decouple-dial-from-call.md — ADR-091
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md — ADR-034 (verifier selection)
- crates/alknet-tls/src/client.rs —
TlsClientConfig::new+for_quinn(the TLS config the dial consumes) - crates/alknet-core/src/types.rs —
Connection::from_quinn_with_alpn(lines 519-526) - crates/alknet-core/src/credentials.rs —
ConnectionCredentials(the credential bundle) - crates/alknet-call/src/client/call_client.rs — old
connect()(lines 142-168, reference for what NOT to replicate)
Notes
This is the primary dial method — QUIC is the default transport for native alknet connections. The implementation is straightforward because
TlsClientConfigandConnection::from_quinn_with_alpnalready exist. The key difference from the oldconnect(): ALPN is a parameter (not hardcoded), the endpoint is pre-built (not constructed internally), credentials areConnectionCredentials(notCallCredentials), and the return type isConnection(notCallConnection). The SOCKS5 proxy path is a separate task — this task implements the direct path.
Summary
To be filled on completion