Phase 3 complete: alknet-client crate implemented with: - AlknetClient struct with builder methods (with_quinn, with_tcp_tls, with_iroh, with_socks5_proxy) - ClientDialError enum with 5 variants - dial_quic, dial_tcp_tls, dial_iroh methods - SOCKS5 proxy support (Socks5ProxyConfig, Socks5Credentials, Socks5UdpSocket) - Unit tests (18) + integration test (3) - All feature combos compile and test clean - Workspace build and tests pass
8.1 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| client/client-core | Implement AlknetClient struct, new, and builder methods (with_quinn, with_tcp_tls, with_iroh, with_socks5_proxy) | completed |
|
moderate | medium | component | implementation |
Description
Phase 3, Task 3. Implement the AlknetClient struct and its builder methods in
crates/alknet-client/src/client.rs. This is the central type — the client-side
analogue of AlknetEndpoint. Holds pre-built transport handles, all optional —
the client dials with whichever transport the remote endpoint type implies.
This is a fresh build against the ADR-089/090/091 shape, not a copy of the old
CallClient::connect. The old connect() built transports internally from
CallCredentials; the new AlknetClient receives pre-built transports via builder
methods, mirroring AlknetEndpoint's builder pattern (ADR-083).
Target shape (per architecture spec)
use std::sync::Arc;
#[cfg(feature = "quinn")]
use quinn;
#[cfg(feature = "tcp")]
use tokio_rustls;
#[cfg(feature = "iroh")]
use iroh;
#[cfg(feature = "socks5")]
use crate::socks5::Socks5ProxyConfig;
/// Native client dial seam — multi-transport dialer that produces
/// `Connection`s for protocol take-overs.
///
/// Holds pre-built transport handles, all optional — the client dials
/// with whichever transport the remote endpoint type implies. The
/// builder mirrors `AlknetEndpoint`'s `with_quinn` / `with_iroh` /
/// `with_tcp_tls` (ADR-083) — the assembly layer builds the transport
/// handles and hands them to the client via builder methods.
pub struct AlknetClient {
#[cfg(feature = "quinn")]
quinn: Option<quinn::Endpoint>,
#[cfg(feature = "tcp")]
tcp_connector: Option<tokio_rustls::TlsConnector>,
#[cfg(feature = "iroh")]
iroh: Option<iroh::Endpoint>,
/// When set, `dial_quic` and `dial_tcp_tls` route through this
/// SOCKS5 proxy (UDP ASSOCIATE / CONNECT respectively). `dial_iroh`
/// forces relay-only via an HTTP-to-SOCKS5 bridge — see ADR-090 §5.
/// Feature-gated on `socks5`.
#[cfg(feature = "socks5")]
socks5: Option<Socks5ProxyConfig>,
}
impl AlknetClient {
/// Create a new `AlknetClient` with no transport handles configured.
/// Use the builder methods to add transports.
pub fn new() -> Self {
Self {
#[cfg(feature = "quinn")]
quinn: None,
#[cfg(feature = "tcp")]
tcp_connector: None,
#[cfg(feature = "iroh")]
iroh: None,
#[cfg(feature = "socks5")]
socks5: None,
}
}
/// Set the QUIC transport handle. The assembly layer builds a
/// `quinn::Endpoint` (with or without a SOCKS5 proxy — the proxy
/// is applied inside `dial_quic`, not at construction time) and
/// hands it to the client.
#[cfg(feature = "quinn")]
pub fn with_quinn(mut self, endpoint: quinn::Endpoint) -> Self {
self.quinn = Some(endpoint);
self
}
/// Set the TCP+TLS transport handle. The assembly layer builds a
/// `tokio_rustls::TlsConnector` and hands it to the client.
#[cfg(feature = "tcp")]
pub fn with_tcp_tls(mut self, connector: tokio_rustls::TlsConnector) -> Self {
self.tcp_connector = Some(connector);
self
}
/// Set the iroh transport handle. The assembly layer builds an
/// `iroh::Endpoint` and hands it to the client.
#[cfg(feature = "iroh")]
pub fn with_iroh(mut self, endpoint: iroh::Endpoint) -> Self {
self.iroh = Some(endpoint);
self
}
/// Set the SOCKS5 proxy for all subsequent dials. When set, every
/// dial routes its transport through this proxy: UDP ASSOCIATE for
/// `dial_quic`, CONNECT for `dial_tcp_tls`, and force-relay-only +
/// HTTP-to-SOCKS5 bridge for `dial_iroh` (ADR-090 §5).
/// Feature-gated on `socks5`.
#[cfg(feature = "socks5")]
pub fn with_socks5_proxy(mut self, proxy: Socks5ProxyConfig) -> Self {
self.socks5 = Some(proxy);
self
}
}
impl Default for AlknetClient {
fn default() -> Self {
Self::new()
}
}
Key design decisions
-
Builder pattern mirrors
AlknetEndpoint:with_quinn,with_iroh,with_tcp_tlsare the same builder method names as the server side (ADR-083). The assembly layer builds the transport handles and hands them to the client. -
new()takes no parameters: NoStaticConfig, noTlsClientConfig, no credentials. The client receives pre-built transports. This is the same pattern asAlknetEndpoint::new(). -
with_tcp_tlstakes aTlsConnector: The assembly layer builds theTlsConnectorfromTlsClientConfig::for_tcp_tls()(or directly from arustls::ClientConfig). The client does not build TLS configs — it receives pre-built connectors. -
with_socks5_proxyis a client-level setting: The proxy is set once on the client (all dials use it), not per-dial. The proxy is a client-level privacy posture, not a per-connection choice. Feature-gated onsocks5. -
No
connect()method: The oldCallClient::connect()welded the dial into the protocol crate.AlknetClienthas three separate dial methods (dial_quic,dial_tcp_tls,dial_iroh) — each is a separate task. -
No protocol take-over: The client produces a
Connection; the caller hands it toCallClient::spawn_dispatchorChannelClient::from_connection.AlknetClientdoes not spawn the dispatch loop.
What this does NOT include
- The dial methods (
dial_quic,dial_tcp_tls,dial_iroh) — separate tasks - The SOCKS5 proxy implementation (
Socks5UdpSocket, HTTP-to-SOCKS5 bridge) — separate task Socks5ProxyConfig/Socks5Credentialstypes — defined insocks5.rs(separate task)- Tests — separate task
Acceptance Criteria
AlknetClientstruct defined incrates/alknet-client/src/client.rs- Fields:
quinn(feature-gated),tcp_connector(feature-gated),iroh(feature-gated),socks5(feature-gated) AlknetClient::new()takes no parameters, initializes all fields toNonewith_quinn(endpoint)builder method (feature-gated onquinn)with_tcp_tls(connector)builder method (feature-gated ontcp)with_iroh(endpoint)builder method (feature-gated oniroh)with_socks5_proxy(proxy)builder method (feature-gated onsocks5)Defaultimpl delegates tonew()Debugimpl (manual or derived — lists which transports are configured, no transport internals)- No
connect()method (the old welded dial is not replicated) - No
StaticConfigparameter onnew()(assembly layer reads it) - No
TlsClientConfigconstruction (client receives pre-built connectors) - No dependency on
alknet-call(dial is below the protocol) - Feature gates correct:
quinn,tcp,iroh,socks5each gate their respective fields/methods cargo check -p alknet-clientsucceeds (all feature combos)cargo clippy -p alknet-clientsucceeds with no warningscargo build --workspacestill succeeds (old code untouched)
References
- docs/architecture/crates/client/README.md —
AlknetClientsection (lines 100-153) - docs/architecture/decisions/089-alknetclient-native-dial-seam.md — ADR-089
- docs/architecture/decisions/090-client-dial-socks5-proxy-seam.md — ADR-090 §1-2
- docs/architecture/decisions/091-connectioncredentials-decouple-dial-from-call.md — ADR-091
- crates/alknet-endpoint/src/endpoint.rs —
AlknetEndpointbuilder pattern (reference) - crates/alknet-call/src/client/call_client.rs — old
CallClientstruct (lines 102-187, reference for what NOT to replicate)
Notes
This is the core structural task of Phase 3. The
AlknetClientis built fresh against the ADR-089/090/091 shape — it's not a copy of the oldCallClient. The key difference: the oldconnect()built transports internally fromCallCredentials; the new client receives pre-built transports via builder methods. The dial methods are separate tasks. The old code incall_client.rsis NOT deleted — that's Phase 5.
Summary
To be filled on completion