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.
7.4 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| client/dial-tcp-tls | Implement dial_tcp_tls — TCP+TLS dial via tokio-rustls, producing a Connection | pending |
|
moderate | medium | component | implementation |
Description
Phase 3, Task 5. Implement AlknetClient::dial_tcp_tls in crates/alknet-client/src/dial/tcp_tls.rs.
The TCP+TLS dial: builds a TlsClientConfig from ConnectionCredentials, constructs a
TlsConnector, connects a TcpStream to addr, wraps with TLS using host as the SNI,
and returns a Connection via Connection::from_bidi (ADR-065).
This is a fresh build — there is no existing TCP+TLS dial in the codebase to reference.
The old CallClient::connect was QUIC-only. This is the second transport's dial, validating
the transport-polymorphic design.
Target shape (per architecture spec)
impl AlknetClient {
/// TCP+TLS dial. Builds a `TlsClientConfig` from `creds`,
/// connects a `TcpStream` to `addr`, wraps with `TlsConnector`
/// using `host` as the SNI, returns a `Connection` via
/// `Connection::from_bidi` (ADR-065). Feature-gated on `tcp`.
#[cfg(feature = "tcp")]
pub async fn dial_tcp_tls(
&self,
host: &str,
addr: SocketAddr,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError>;
}
Implementation outline
#[cfg(feature = "tcp")]
pub async fn dial_tcp_tls(
&self,
host: &str,
addr: SocketAddr,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError> {
// 1. Build TlsClientConfig from credentials + ALPN
let tls_config = TlsClientConfig::new(creds, alpn)?;
// 2. Build or use the TlsConnector
let connector = match &self.tcp_connector {
Some(c) => c.clone(),
None => {
// If no pre-built connector, build one from the TlsClientConfig.
// The assembly layer can either pre-build a TlsConnector (via
// with_tcp_tls) or let the dial build one from the rustls config.
// For now, build from the TlsClientConfig's inner rustls config.
let rustls_config = Arc::new(tls_config.into_rustls_config());
tokio_rustls::TlsConnector::from(rustls_config)
}
};
// 3. Connect TCP
let tcp_stream = TcpStream::connect(addr)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
// 4. TLS handshake
let server_name = rustls::pki_types::ServerName::try_from(host)
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
let tls_stream = connector
.connect(server_name, tcp_stream)
.await
.map_err(|e| ClientDialError::Handshake(e.to_string()))?;
// 5. Wrap as Connection (single bidi stream — ADR-065)
Ok(Connection::from_bidi(
tls_stream,
alpn.to_vec(),
Some(addr),
))
}
Key design decisions
-
hostis the TLS SNI: The hostname for TLS SNI. For X.509 endpoints, this must match the server's certificate. For raw-key endpoints, it's ignored by the verifier. Separate fromaddrbecause the hostname may differ from the IP address (DNS resolution happens at the assembly layer). -
addris theSocketAddr: The IP:port to connect to. The assembly layer resolves the hostname to an address before calling the dial. -
Connection::from_bidi: TCP+TLS is a single-stream transport — there's one bidirectional stream (the TLS-wrapped TCP connection).from_bidisplits it internally viatokio::io::splitand wraps it as aConnection.accept_bi()yields the stream once, thenConnectionClosed(ADR-070's yield-once contract). -
TlsConnectorfrom pre-built or on-the-fly: The assembly layer can either pre-build aTlsConnectorviawith_tcp_tlsor let the dial build one from theTlsClientConfig. The implementation supports both: ifself.tcp_connectorisSome, use it; otherwise build from theTlsClientConfig's inner rustls config. -
HandshakevsConnecterrors: TCP connect failures map toClientDialError::Connect. TLS handshake failures (rejected cert, ALPN mismatch) map toClientDialError::Handshake. This distinction lets callers differentiate "couldn't reach the server" from "the server rejected our identity." -
SOCKS5 proxy path: When
self.socks5isSome, the dial routes through the proxy (CONNECT) instead of connecting directly. The proxy path: connect TCP to the proxy, perform SOCKS5 CONNECT handshake toaddr, then TLS over the proxied stream. This is implemented in theclient/socks5-proxytask — this task implements the direct (no-proxy) path.
What this does NOT include
- The SOCKS5 proxy path — separate task (
client/socks5-proxy) dial_quic— separate taskdial_iroh— separate task- Tests — separate task
Acceptance Criteria
AlknetClient::dial_tcp_tlsimplemented incrates/alknet-client/src/dial/tcp_tls.rs- Signature:
pub async fn dial_tcp_tls(&self, host: &str, addr: SocketAddr, alpn: &[u8], creds: &ConnectionCredentials) -> Result<Connection, ClientDialError> - Feature-gated on
#[cfg(feature = "tcp")] - Builds
TlsClientConfig::new(creds, alpn)—TlsErrorconverts toClientDialError::TlsConfigvia#[from] - Uses pre-built
TlsConnectorfromself.tcp_connectorif available, or builds one fromTlsClientConfig - Connects TCP via
TcpStream::connect(addr) Connecterrors map toClientDialError::Connect(String)- Performs TLS handshake via
connector.connect(server_name, tcp_stream) Handshakeerrors map toClientDialError::Handshake(String)- Returns
Connection::from_bidi(tls_stream, alpn.to_vec(), Some(addr)) - Does NOT call
spawn_dispatch(protocol take-over is caller's concern) - SOCKS5 proxy branch is a
todo!()or conditional on#[cfg(feature = "socks5")](filled in byclient/socks5-proxy) cargo check -p alknet-client --features tcpsucceedscargo clippy -p alknet-client --features tcpsucceeds with no warningscargo build --workspacestill succeeds (old code untouched)
References
- docs/architecture/crates/client/README.md —
dial_tcp_tlssection (lines 173-184) - 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/065-connection-from-stream-generic-single-stream.md — ADR-065 (
Connection::from_bidi) - docs/architecture/decisions/070-bidistreamsource-trait.md — ADR-070 (yield-once contract)
- crates/alknet-tls/src/client.rs —
TlsClientConfig::new(the TLS config the dial consumes) - crates/alknet-core/src/types.rs —
Connection::from_bidi(lines 562-569) - crates/alknet-core/src/credentials.rs —
ConnectionCredentials(the credential bundle)
Notes
This is the second transport's dial — the one that validates the transport-polymorphic design. There is no existing TCP+TLS dial in the codebase to reference (the old
connect()was QUIC-only). The implementation is straightforward becauseTlsClientConfig,TlsConnector, andConnection::from_bidialready exist. TheTlsConnectorcan be pre-built by the assembly layer (viawith_tcp_tls) or built on-the-fly from theTlsClientConfig. The SOCKS5 proxy path is a separate task.
Summary
To be filled on completion