feat(tasks): decompose Phase 3 alknet-client into 9 atomic tasks

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.
This commit is contained in:
deepseek-v4-pro committed 2026-07-17 12:07:08 +00:00
1 parent ab56acae69
commit b476182d64
9 files changed
+1818

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
---
id: client/client-core
name: Implement AlknetClient struct, new, and builder methods (with_quinn, with_tcp_tls, with_iroh, with_socks5_proxy)
status: pending
depends_on: [client/error-type]
scope: moderate
risk: medium
impact: component
level: 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)
```rust
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
1. **Builder pattern mirrors `AlknetEndpoint`**: `with_quinn`, `with_iroh`, `with_tcp_tls`
are the same builder method names as the server side (ADR-083). The assembly layer
builds the transport handles and hands them to the client.
2. **`new()` takes no parameters**: No `StaticConfig`, no `TlsClientConfig`, no
credentials. The client receives pre-built transports. This is the same pattern
as `AlknetEndpoint::new()`.
3. **`with_tcp_tls` takes a `TlsConnector`**: The assembly layer builds the
`TlsConnector` from `TlsClientConfig::for_tcp_tls()` (or directly from a
`rustls::ClientConfig`). The client does not build TLS configs — it receives
pre-built connectors.
4. **`with_socks5_proxy` is 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 on `socks5`.
5. **No `connect()` method**: The old `CallClient::connect()` welded the dial into
the protocol crate. `AlknetClient` has three separate dial methods
(`dial_quic`, `dial_tcp_tls`, `dial_iroh`) — each is a separate task.
6. **No protocol take-over**: The client produces a `Connection`; the caller hands
it to `CallClient::spawn_dispatch` or `ChannelClient::from_connection`.
`AlknetClient` does 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` / `Socks5Credentials` types — defined in `socks5.rs` (separate task)
- Tests — separate task
## Acceptance Criteria
- [ ] `AlknetClient` struct defined in `crates/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 to `None`
- [ ] `with_quinn(endpoint)` builder method (feature-gated on `quinn`)
- [ ] `with_tcp_tls(connector)` builder method (feature-gated on `tcp`)
- [ ] `with_iroh(endpoint)` builder method (feature-gated on `iroh`)
- [ ] `with_socks5_proxy(proxy)` builder method (feature-gated on `socks5`)
- [ ] `Default` impl delegates to `new()`
- [ ] `Debug` impl (manual or derived — lists which transports are configured, no transport internals)
- [ ] No `connect()` method (the old welded dial is not replicated)
- [ ] No `StaticConfig` parameter on `new()` (assembly layer reads it)
- [ ] No `TlsClientConfig` construction (client receives pre-built connectors)
- [ ] No dependency on `alknet-call` (dial is below the protocol)
- [ ] Feature gates correct: `quinn`, `tcp`, `iroh`, `socks5` each gate their respective fields/methods
- [ ] `cargo check -p alknet-client` succeeds (all feature combos)
- [ ] `cargo clippy -p alknet-client` succeeds with no warnings
- [ ] `cargo build --workspace` still succeeds (old code untouched)
## References
- docs/architecture/crates/client/README.md — `AlknetClient` section (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 — `AlknetEndpoint` builder pattern (reference)
- crates/alknet-call/src/client/call_client.rs — old `CallClient` struct (lines 102-187, reference for what NOT to replicate)
## Notes
> This is the core structural task of Phase 3. The `AlknetClient` is built fresh
> against the ADR-089/090/091 shape — it's not a copy of the old `CallClient`.
> The key difference: the old `connect()` built transports internally from
> `CallCredentials`; the new client receives pre-built transports via builder
> methods. The dial methods are separate tasks. The old code in `call_client.rs`
> is NOT deleted — that's Phase 5.
## Summary
> To be filled on completion
+155
View File
@@ -0,0 +1,155 @@
---
id: client/crate-init
name: Initialize alknet-client crate with Cargo.toml, dependencies, and module skeleton
status: pending
depends_on: [tls/review-tls, endpoint/review-endpoint]
scope: moderate
risk: low
impact: project
level: implementation
---
## Description
Phase 3, Task 1 of the crate extraction (per `docs/research/alknet-crate-extraction/findings.md`).
Initialize the `alknet-client` crate from scratch. This crate provides the native client dial
seam — `AlknetClient`, the client-side analogue of `AlknetEndpoint`. A multi-transport dialer
that takes pre-built transport handles (quinn, TCP+TLS, iroh), dials a remote `AlknetEndpoint`
on a chosen ALPN, and produces a `Connection` for the protocol take-overs to consume.
The dial is the client-side mirror of the server-side accept loop (ADR-083): one type that
takes pre-built transport handles and produces a `Connection`, with the transport choice as a
parameter. The protocol take-overs (`CallClient::spawn_dispatch`, `ChannelClient::from_connection`)
are unchanged — they consume the `Connection` and do not know `AlknetClient` produced it.
### Crate setup
Create `crates/alknet-client/` with:
- `Cargo.toml` — package metadata, dependencies, feature flags
- `src/lib.rs` — crate root with module declarations and re-exports
- Module skeleton files for:
- `src/error.rs` — `ClientDialError` enum (5 variants: `TlsConfig`, `Connect`, `Handshake`, `NoTransport`, `Proxy`)
- `src/client.rs` — `AlknetClient` struct, `new`, builder methods (`with_quinn`, `with_tcp_tls`, `with_iroh`, `with_socks5_proxy`)
- `src/dial/quinn.rs` — `dial_quic` implementation (feature-gated on `quinn`)
- `src/dial/tcp_tls.rs` — `dial_tcp_tls` implementation (feature-gated on `tcp`)
- `src/dial/iroh.rs` — `dial_iroh` implementation (feature-gated on `iroh`)
- `src/dial/mod.rs` — dial module declarations
- `src/socks5.rs` — `Socks5ProxyConfig`, `Socks5Credentials`, `Socks5UdpSocket`, HTTP-to-SOCKS5 bridge (feature-gated on `socks5`)
### Dependencies
Per the findings (Phase 3) and the architecture spec:
| Crate | Purpose |
|-------|---------|
| `alknet-core` | `Connection`, `ConnectionCredentials`, `RemoteIdentity`, `Ed25519SecretKey`, types (workspace path) |
| `alknet-tls` | `TlsClientConfig` — for quinn + tcp dials (workspace path) |
| `quinn` 0.11 | QUIC transport (optional, feature-gated) |
| `tokio-rustls` 0.26 | TCP+TLS transport (optional, feature-gated) |
| `iroh` 1.0 | Iroh transport (optional, feature-gated) |
| `fast-socks5` 1 | SOCKS5 client (optional, feature-gated — ADR-090) |
| `tokio` 1 (full) | Async runtime, TcpStream, spawn |
| `thiserror` 2 | `ClientDialError` |
`alknet-client` depends on `alknet-tls` (for `TlsClientConfig`) and `alknet-core` (for
`Connection`, `ConnectionCredentials`, `RemoteIdentity`, and types). It does **not** depend
on `alknet-call` or `alknet-channels-call` — the dial is below the protocol.
### Feature flags
```toml
[features]
default = []
quinn = ["dep:quinn", "alknet-tls/quinn", "alknet-core/quinn"] # dial_quic
tcp = ["dep:tokio-rustls", "alknet-tls/tcp"] # dial_tcp_tls
iroh = ["dep:iroh", "alknet-core/iroh"] # dial_iroh
socks5 = ["dep:fast-socks5"] # proxied dial paths (ADR-090)
```
The `quinn` and `tcp` features pull the corresponding features on `alknet-tls` (for
`TlsClientConfig::for_quinn` / `for_tcp_tls`). The `quinn` and `iroh` features also pull
the corresponding features on `alknet-core` — `dial_quic` produces a `Connection` via
`Connection::from_quinn_with_alpn` and `dial_iroh` via `Connection::from_iroh`, both of
which live in `alknet-core`'s `types.rs` behind core's `quinn` / `iroh` features.
The `socks5` feature (ADR-090) is independent of the transport features — it enables the
proxy code path that `dial_quic` (UDP ASSOCIATE) and `dial_tcp_tls` (CONNECT) use when a
proxy is configured. The `fast-socks5` dep is behind `socks5`, so deployments that don't
use a proxy don't pay the dep.
### Workspace Cargo.toml
Add `crates/alknet-client` to the workspace `members` list in the root `Cargo.toml`.
### Module skeleton
```rust
// src/lib.rs
//! alknet-client: Native client dial seam — multi-transport dialer that
//! produces `Connection`s for protocol take-overs.
//!
//! `AlknetClient` is the client-side analogue of `AlknetEndpoint`: a
//! multi-transport dialer that takes pre-built transport handles (quinn,
//! TCP+TLS, iroh), dials a remote `AlknetEndpoint` on a chosen ALPN, and
//! produces a `Connection`. The protocol take-overs
//! (`CallClient::spawn_dispatch`, `ChannelClient::from_connection`)
//! consume the `Connection` — the dial is below the protocol.
//!
//! An optional SOCKS5 proxy (ADR-090) routes the dials through a proxy
//! to hide the client's real IP from the hub.
pub mod client;
pub mod dial;
pub mod error;
#[cfg(feature = "socks5")]
pub mod socks5;
// Re-exports (filled in by subsequent tasks)
```
Each module file gets a doc comment and `// TODO: implement` marker.
## Acceptance Criteria
- [ ] `crates/alknet-client/Cargo.toml` exists with all dependencies and feature flags
- [ ] `crates/alknet-client/src/lib.rs` exists with module declarations
- [ ] Module skeleton files exist: `error.rs`, `client.rs`, `dial/mod.rs`, `dial/quinn.rs`, `dial/tcp_tls.rs`, `dial/iroh.rs`, `socks5.rs`
- [ ] Root `Cargo.toml` `members` list includes `crates/alknet-client`
- [ ] `cargo check -p alknet-client` succeeds
- [ ] `cargo clippy -p alknet-client` succeeds with no warnings
- [ ] Dual licensing: `MIT OR Apache-2.0` (workspace-inherited)
- [ ] `alknet-core` dependency uses workspace path (`path = "../alknet-core"`)
- [ ] `alknet-tls` dependency uses workspace path (`path = "../alknet-tls"`)
- [ ] No dependency on `alknet-call` (dial is below the protocol)
- [ ] Feature flags: `quinn`, `tcp`, `iroh`, `socks5` (all optional, default off)
- [ ] `quinn` feature pulls `alknet-tls/quinn` + `alknet-core/quinn`
- [ ] `tcp` feature pulls `alknet-tls/tcp`
- [ ] `iroh` feature pulls `alknet-core/iroh`
- [ ] `socks5` feature pulls `dep:fast-socks5` only (no alknet feature deps)
- [ ] `cargo build --workspace` still succeeds (old code untouched)
## References
- docs/research/alknet-crate-extraction/findings.md — Phase 3
- docs/architecture/crates/client/README.md — full architecture spec
- docs/architecture/decisions/089-alknetclient-native-dial-seam.md — ADR-089
- docs/architecture/decisions/090-client-dial-socks5-proxy-seam.md — ADR-090
- docs/architecture/decisions/091-connectioncredentials-decouple-dial-from-call.md — ADR-091
- crates/alknet-tls/Cargo.toml — reference for dep versions and feature flag pattern
- crates/alknet-endpoint/Cargo.toml — reference for builder-method pattern
- crates/alknet-call/src/client/call_client.rs — reference `connect()` implementation (lines 142-168)
## Notes
> This is the foundational setup task for alknet-client. All subsequent client/*
> tasks depend on this one. The crate depends on `alknet-tls` (for `TlsClientConfig`)
> and `alknet-core` (for `Connection`, `ConnectionCredentials`, `RemoteIdentity`).
> It does NOT depend on `alknet-call` — the dial is below the protocol. The old
> `CallClient::connect` in `alknet-call` is intentionally still present (duplicated)
> — the prune happens in Phase 5. The `socks5` feature and `fast-socks5` dep are
> opt-in; deployments that don't use a proxy pay nothing.
## Summary
> To be filled on completion
+202
View File
@@ -0,0 +1,202 @@
---
id: client/dial-iroh
name: Implement dial_iroh — iroh dial, producing a Connection
status: pending
depends_on: [client/client-core]
scope: moderate
risk: medium
impact: component
level: implementation
---
## Description
Phase 3, Task 6. Implement `AlknetClient::dial_iroh` in `crates/alknet-client/src/dial/iroh.rs`.
The iroh dial: extracts the `Ed25519SecretKey` from `ConnectionCredentials.local_identity`,
derives the remote `NodeId` from `creds.remote_identity.fingerprint`, dials on `alpn` via
the iroh endpoint, and returns a `Connection` via `Connection::from_iroh`.
This is the **key-not-config** dial — iroh has its own TLS (shares the key, not the
rustls config — ADR-087 §3). The dial does NOT use `TlsClientConfig`. The consistency
is in the rule (ADR-034 verifier selection), not in the type.
### Target shape (per architecture spec)
```rust
impl AlknetClient {
/// Iroh dial. Dials on `alpn` via the iroh endpoint. The iroh path
/// does NOT use `TlsClientConfig` — iroh has its own TLS (shares the
/// `Ed25519SecretKey`, not the rustls config — ADR-087 §3, ADR-089
/// §3). The local key is extracted from `creds.local_identity`; the
/// remote `NodeId` is derived from `creds.remote_identity.fingerprint`
/// (`ed25519:<hex>` → `NodeId::from_bytes`). The verifier is iroh's
/// `NodeId` match (fingerprint pin by another name — ADR-034 §3).
/// An unknown iroh remote fails closed (no CA). Feature-gated on
/// `iroh`.
#[cfg(feature = "iroh")]
pub async fn dial_iroh(
&self,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError>;
}
```
### Implementation outline
```rust
#[cfg(feature = "iroh")]
pub async fn dial_iroh(
&self,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError> {
// 1. Get the iroh endpoint
let endpoint = match &self.iroh {
Some(ep) => ep.clone(),
None => return Err(ClientDialError::NoTransport { transport: "iroh" }),
};
// 2. Extract the remote NodeId from credentials
let node_id = match &creds.remote_identity {
Some(ri) => {
// fingerprint format: "ed25519:<hex>" or "SHA256:<base64>"
// For iroh, we need the ed25519 hex bytes → NodeId
extract_iroh_node_id(&ri.fingerprint)
.map_err(|e| ClientDialError::TlsConfig(
alknet_tls::TlsError::Config(e)
))?
}
None => {
// Unknown iroh remote — fail closed (no CA to fall back to)
return Err(ClientDialError::TlsConfig(
alknet_tls::TlsError::Config(
"iroh requires a known remote (remote_identity must be Some); \
unknown iroh remotes fail closed (ADR-034 §3)".into()
)
));
}
};
// 3. Connect via iroh
let conn = endpoint
.connect(node_id, alpn)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
// 4. Wrap as Connection
Ok(Connection::from_iroh(conn))
}
```
### Key design decisions
1. **No `TlsClientConfig`**: iroh has its own TLS. The dial does not use
`TlsClientConfig` at all — it extracts the key and fingerprint directly from
`ConnectionCredentials`. This is the same exception as the server side
(ADR-082, ADR-087 §3).
2. **`node_id` derived from `remote_identity.fingerprint`**: The fingerprint string
(e.g., `"ed25519:abcdef123456..."`) is parsed to extract the Ed25519 public key
bytes, then converted to `iroh::NodeId::from_bytes`. This is the same extraction
pattern the rustls dials use for the verifier — the consistency is in the rule
(ADR-034), not in the type.
3. **Unknown iroh remote fails closed**: `remote_identity: None` with iroh returns
a `TlsConfig` error. There is no CA to fall back to for iroh — raw-key remotes
are always known peers (ADR-034 §2, Assumption 1).
4. **No `addr` or `server_name` parameter**: iroh handles addressing internally
(via relays, hole-punching, etc.). The dial only needs the `NodeId` and ALPN.
5. **Returns `Connection`, not `CallConnection`**: Same as the other dials — the
protocol take-over is the caller's concern.
6. **SOCKS5 proxy path**: When `self.socks5` is `Some`, the iroh endpoint should
have been built with force-relay-only + `proxy_url` by the assembly layer
(ADR-090 §5). The dial itself doesn't change — the proxy is applied at
endpoint construction time, not at dial time. This task does not need to
handle the proxy path specially.
### Helper: `extract_iroh_node_id`
```rust
/// Extract an `iroh::NodeId` from a fingerprint string.
///
/// Supports two formats:
/// - `"ed25519:<hex>"` — raw Ed25519 public key (64 hex chars)
/// - `"SHA256:<base64>"` — SHA-256 hash of the cert (for X.509; not valid for iroh)
///
/// For iroh, only the `ed25519:` prefix is valid — iroh uses Ed25519 keys.
fn extract_iroh_node_id(fingerprint: &str) -> Result<iroh::NodeId, String> {
if let Some(hex) = fingerprint.strip_prefix("ed25519:") {
let bytes = hex::decode(hex).map_err(|e| format!("invalid ed25519 fingerprint hex: {e}"))?;
if bytes.len() != 32 {
return Err(format!(
"invalid ed25519 fingerprint length: expected 32 bytes, got {}",
bytes.len()
));
}
let arr: [u8; 32] = bytes.try_into().map_err(|_| "invalid ed25519 fingerprint length".to_string())?;
Ok(iroh::NodeId::from_bytes(&arr)?)
} else {
Err(format!(
"iroh requires an ed25519: fingerprint, got: {}",
fingerprint
))
}
}
```
### What this does NOT include
- The SOCKS5 proxy path for iroh — the proxy is applied at endpoint construction time
by the assembly layer (ADR-090 §5), not at dial time
- `dial_quic` — separate task
- `dial_tcp_tls` — separate task
- Tests — separate task
## Acceptance Criteria
- [ ] `AlknetClient::dial_iroh` implemented in `crates/alknet-client/src/dial/iroh.rs`
- [ ] Signature: `pub async fn dial_iroh(&self, alpn: &[u8], creds: &ConnectionCredentials) -> Result<Connection, ClientDialError>`
- [ ] Feature-gated on `#[cfg(feature = "iroh")]`
- [ ] Uses pre-built iroh endpoint from `self.iroh` (cloned)
- [ ] Returns `NoTransport` error when `self.iroh` is `None`
- [ ] Extracts `NodeId` from `creds.remote_identity.fingerprint` (supports `ed25519:<hex>` format)
- [ ] Unknown iroh remote (`remote_identity: None`) fails closed with `TlsConfig` error
- [ ] Connects via `endpoint.connect(node_id, alpn)`
- [ ] `Connect` errors map to `ClientDialError::Connect(String)`
- [ ] Returns `Connection::from_iroh(conn)`
- [ ] Does NOT use `TlsClientConfig` (iroh has its own TLS)
- [ ] Does NOT call `spawn_dispatch` (protocol take-over is caller's concern)
- [ ] Does NOT take `addr` or `server_name` parameters (iroh handles addressing internally)
- [ ] `cargo check -p alknet-client --features iroh` succeeds
- [ ] `cargo clippy -p alknet-client --features iroh` succeeds with no warnings
- [ ] `cargo build --workspace` still succeeds (old code untouched)
## References
- docs/architecture/crates/client/README.md — `dial_iroh` section (lines 186-201)
- 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/087-tlsclientconfig-not-blocked-on-dial.md — ADR-087 §3 (iroh shares the key, not the config)
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md — ADR-034 §2-3 (verifier selection, fail-closed for unknown raw-key)
- docs/architecture/decisions/090-client-dial-socks5-proxy-seam.md — ADR-090 §5 (iroh proxy: force relay-only, applied at endpoint construction)
- crates/alknet-core/src/types.rs — `Connection::from_iroh` (lines 528-536)
- crates/alknet-core/src/credentials.rs — `ConnectionCredentials` (the credential bundle)
- crates/alknet-core/src/config.rs — `Ed25519SecretKey` (the key type)
## Notes
> This is the key-not-config dial — iroh has its own TLS and does not use
> `TlsClientConfig`. The dial extracts the key and fingerprint directly from
> `ConnectionCredentials`. The `node_id` is derived from `remote_identity.fingerprint`
> (the same extraction pattern the rustls dials use for the verifier). Unknown iroh
> remotes fail closed (no CA to fall back to). The SOCKS5 proxy for iroh is applied
> at endpoint construction time by the assembly layer (force relay-only +
> `proxy_url`), not at dial time — this task does not need to handle it.
## Summary
> To be filled on completion
+192
View File
@@ -0,0 +1,192 @@
---
id: client/dial-quic
name: Implement dial_quic — QUIC dial via quinn, producing a Connection
status: pending
depends_on: [client/client-core]
scope: moderate
risk: medium
impact: component
level: 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)
```rust
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
```rust
#[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
1. **`TlsClientConfig::new(creds, alpn)`**: The TLS config is built from
`ConnectionCredentials` (ADR-091) — the unified transport-level credential bundle.
The `TlsError` from config construction is converted to `ClientDialError::TlsConfig`
via the `#[from]` impl.
2. **`server_name` is 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.
3. **`alpn` is 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.
4. **Returns `Connection`, not `CallConnection`**: The old `connect()` returned a
`CallConnection` (welding the dial to the call protocol). The new `dial_quic`
returns a `Connection` — the caller hands it to `CallClient::spawn_dispatch` or
`ChannelClient::from_connection`.
5. **`NoTransport` error when `with_quinn` not set**: The dial checks that a quinn
endpoint was configured. If not, it returns `ClientDialError::NoTransport`.
6. **SOCKS5 proxy path**: When `self.socks5` is `Some`, the dial routes through the
proxy (UDP ASSOCIATE) instead of using the pre-built quinn endpoint directly.
This is implemented in the `client/socks5-proxy` task — this task implements the
direct (no-proxy) path. The proxy integration point is a conditional branch:
```rust
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!()` or `unimplemented!()` — the
`socks5-proxy` task fills it in.
### Reference: the old `connect()` (what we're replacing)
The old `CallClient::connect` (lines 142-168 of `call_client.rs`):
```rust
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 task
- `dial_iroh` — separate task
- Tests — separate task
## Acceptance Criteria
- [ ] `AlknetClient::dial_quic` implemented in `crates/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)` — `TlsError` converts to `ClientDialError::TlsConfig` via `#[from]`
- [ ] Converts to `quinn::ClientConfig` via `tls_config.for_quinn()`
- [ ] Uses pre-built quinn endpoint from `self.quinn` (cloned)
- [ ] Returns `NoTransport` error when `self.quinn` is `None`
- [ ] Connects via `endpoint.connect_with(client_config, addr, server_name)`
- [ ] `Connect` errors (pre- and post-handshake) map to `ClientDialError::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/call` ALPN (ALPN is a parameter)
- [ ] SOCKS5 proxy branch is a `todo!()` or conditional on `#[cfg(feature = "socks5")]` (filled in by `client/socks5-proxy`)
- [ ] `cargo check -p alknet-client --features quinn` succeeds
- [ ] `cargo clippy -p alknet-client --features quinn` succeeds with no warnings
- [ ] `cargo build --workspace` still succeeds (old code untouched)
## References
- docs/architecture/crates/client/README.md — `dial_quic` section (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 `TlsClientConfig` and
> `Connection::from_quinn_with_alpn` already exist. The key difference from the old
> `connect()`: ALPN is a parameter (not hardcoded), the endpoint is pre-built (not
> constructed internally), credentials are `ConnectionCredentials` (not
> `CallCredentials`), and the return type is `Connection` (not `CallConnection`).
> The SOCKS5 proxy path is a separate task — this task implements the direct path.
## Summary
> To be filled on completion
+169
View File
@@ -0,0 +1,169 @@
---
id: client/dial-tcp-tls
name: Implement dial_tcp_tls — TCP+TLS dial via tokio-rustls, producing a Connection
status: pending
depends_on: [client/client-core]
scope: moderate
risk: medium
impact: component
level: 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)
```rust
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
```rust
#[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
1. **`host` is 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 from `addr` because the hostname may differ from the IP address (DNS
resolution happens at the assembly layer).
2. **`addr` is the `SocketAddr`**: The IP:port to connect to. The assembly layer
resolves the hostname to an address before calling the dial.
3. **`Connection::from_bidi`**: TCP+TLS is a single-stream transport — there's one
bidirectional stream (the TLS-wrapped TCP connection). `from_bidi` splits it
internally via `tokio::io::split` and wraps it as a `Connection`. `accept_bi()`
yields the stream once, then `ConnectionClosed` (ADR-070's yield-once contract).
4. **`TlsConnector` from pre-built or on-the-fly**: The assembly layer can either
pre-build a `TlsConnector` via `with_tcp_tls` or let the dial build one from the
`TlsClientConfig`. The implementation supports both: if `self.tcp_connector` is
`Some`, use it; otherwise build from the `TlsClientConfig`'s inner rustls config.
5. **`Handshake` vs `Connect` errors**: TCP connect failures map to
`ClientDialError::Connect`. TLS handshake failures (rejected cert, ALPN mismatch)
map to `ClientDialError::Handshake`. This distinction lets callers differentiate
"couldn't reach the server" from "the server rejected our identity."
6. **SOCKS5 proxy path**: When `self.socks5` is `Some`, the dial routes through the
proxy (CONNECT) instead of connecting directly. The proxy path: connect TCP to the
proxy, perform SOCKS5 CONNECT handshake to `addr`, then TLS over the proxied stream.
This is implemented in the `client/socks5-proxy` task — 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 task
- `dial_iroh` — separate task
- Tests — separate task
## Acceptance Criteria
- [ ] `AlknetClient::dial_tcp_tls` implemented in `crates/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)` — `TlsError` converts to `ClientDialError::TlsConfig` via `#[from]`
- [ ] Uses pre-built `TlsConnector` from `self.tcp_connector` if available, or builds one from `TlsClientConfig`
- [ ] Connects TCP via `TcpStream::connect(addr)`
- [ ] `Connect` errors map to `ClientDialError::Connect(String)`
- [ ] Performs TLS handshake via `connector.connect(server_name, tcp_stream)`
- [ ] `Handshake` errors map to `ClientDialError::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 by `client/socks5-proxy`)
- [ ] `cargo check -p alknet-client --features tcp` succeeds
- [ ] `cargo clippy -p alknet-client --features tcp` succeeds with no warnings
- [ ] `cargo build --workspace` still succeeds (old code untouched)
## References
- docs/architecture/crates/client/README.md — `dial_tcp_tls` section (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 because
> `TlsClientConfig`, `TlsConnector`, and `Connection::from_bidi` already exist.
> The `TlsConnector` can be pre-built by the assembly layer (via `with_tcp_tls`) or
> built on-the-fly from the `TlsClientConfig`. The SOCKS5 proxy path is a separate task.
## Summary
> To be filled on completion
+132
View File
@@ -0,0 +1,132 @@
---
id: client/error-type
name: Implement ClientDialError enum with five variants
status: pending
depends_on: [client/crate-init]
scope: narrow
risk: low
impact: component
level: implementation
---
## Description
Phase 3, Task 2. Implement the `ClientDialError` enum in `crates/alknet-client/src/error.rs`.
This is the error type for all three dial methods (`dial_quic`, `dial_tcp_tls`, `dial_iroh`).
A single `#[non_exhaustive]` enum, one variant per failure category.
### Target shape (per ADR-089 / architecture spec)
```rust
use thiserror::Error;
/// Errors produced by `AlknetClient` dial methods.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ClientDialError {
/// TLS config construction failure — `TlsClientConfig::new` failed
/// (verifier build, cert load, provider init). Wraps `TlsError`
/// from alknet-tls.
#[error("TLS config construction: {0}")]
TlsConfig(#[from] alknet_tls::TlsError),
/// Transport connect failure — quinn connect, TcpStream::connect,
/// or iroh connect. The transport's own error type, stringified.
#[error("transport connect: {0}")]
Connect(String),
/// TLS handshake failure — the handshake started but failed
/// (rejected cert, ALPN mismatch, unknown raw-key remote
/// fail-closed). Distinct from TlsConfig (which is pre-handshake).
#[error("TLS handshake: {0}")]
Handshake(String),
/// No transport handle configured for the requested dial — e.g.,
/// `dial_quic` called but `with_quinn` was not set.
#[error("no transport handle configured for {transport}")]
NoTransport { transport: &'static str },
/// SOCKS5 proxy failure — handshake rejected, UDP ASSOCIATE
/// unsupported, auth failed, or the proxy closed the control
/// connection (ADR-090). The dial did not reach the remote; the
/// caller decides whether to fall back to a direct dial or
/// surface the error. The dial never silently falls back — that
/// would defeat the privacy posture.
#[cfg(feature = "socks5")]
#[error("SOCKS5 proxy: {0}")]
Proxy(String),
}
```
### Design rationale
**`TlsConfig` wraps `alknet_tls::TlsError`** (ADR-088) — the config construction errors.
This is the only variant that wraps a concrete error type via `#[from]`, because
`TlsError` has one source crate (rustls + pemfile + rcgen).
**`Connect(String)` and `Handshake(String)` take `String`** rather than wrapping the
concrete transport error types (`quinn::ConnectError`, `io::Error`, `rustls::Error`)
because the three transports' error types are non-unifiable — the dial is
transport-polymorphic, and there is no single source type that covers quinn,
tokio-rustls, and iroh. The category is in the variant (`Connect` vs `Handshake`);
the detail is in the string.
**`Handshake` resolves an ADR-088 §6 deferral.** ADR-088 §6 explicitly scoped
`TlsError` to config-construction errors and deferred the handshake-error surfacing
question to the dial-seam ADR. `ClientDialError::Handshake` is the resolution:
handshake-time errors (rejected cert, ALPN mismatch, unknown-raw-key fail-closed)
surface through the dial's error type as `Handshake(String)`, not through `TlsError`
(which stays config-construction-only).
**`NoTransport`** is a wiring error — calling a dial without the matching `with_*`
builder method. The `transport` field is a `&'static str` like `"quinn"`, `"tcp"`,
or `"iroh"`.
**`Proxy`** (ADR-090) is the SOCKS5 proxy failure category — the dial's transport
never reached the remote because the proxy rejected or dropped the association.
Feature-gated on `socks5`. Takes `String` for the same reason `Connect` and
`Handshake` do — the proxy error source type is an implementation detail.
### What this does NOT include
- No `EndpointError` equivalent — the endpoint's error type was removed per ADR-083.
`ClientDialError` is the client-side error type, not a shared type.
- No `From<io::Error>` impl — the dials convert `io::Error` to `Connect(String)`
or `Handshake(String)` at the call site, not via blanket `From`.
## Acceptance Criteria
- [ ] `ClientDialError` enum defined in `crates/alknet-client/src/error.rs`
- [ ] Five variants: `TlsConfig`, `Connect`, `Handshake`, `NoTransport`, `Proxy`
- [ ] `TlsConfig` wraps `alknet_tls::TlsError` via `#[from]`
- [ ] `Connect` and `Handshake` take `String` (not concrete transport error types)
- [ ] `NoTransport` has `transport: &'static str` field
- [ ] `Proxy` is feature-gated on `#[cfg(feature = "socks5")]`
- [ ] All variants have `#[error("...")]` messages
- [ ] `#[non_exhaustive]` attribute on the enum
- [ ] `Debug` derived
- [ ] Re-exported from `lib.rs` (or via `pub mod error; pub use error::ClientDialError;`)
- [ ] `cargo check -p alknet-client` succeeds
- [ ] `cargo clippy -p alknet-client` succeeds with no warnings
- [ ] `cargo build --workspace` still succeeds
## References
- docs/architecture/crates/client/README.md — `ClientDialError` section (lines 460-530)
- docs/architecture/decisions/089-alknetclient-native-dial-seam.md — ADR-089
- docs/architecture/decisions/088-webpki-roots-fallback.md — ADR-088 §6 (handshake error deferral)
- docs/architecture/decisions/090-client-dial-socks5-proxy-seam.md — ADR-090 (Proxy variant)
- crates/alknet-tls/src/lib.rs — `TlsError` definition (reference for the wrapped type)
## Notes
> This is a small, self-contained task. The error type is used by all three dial
> methods (tasks client/dial-quic, client/dial-tcp-tls, client/dial-iroh) and the
> SOCKS5 proxy path (client/socks5-proxy). `Connect(String)` and `Handshake(String)`
> take `String` rather than wrapping concrete transport error types because the
> three transports' error types are non-unifiable. The `Proxy` variant is
> feature-gated on `socks5` per ADR-090.
## Summary
> To be filled on completion
+161
View File
@@ -0,0 +1,161 @@
---
id: client/review-client
name: Review alknet-client implementation for spec conformance, API shape, and test coverage
status: pending
depends_on: [client/tests]
scope: moderate
risk: low
impact: phase
level: review
---
## Description
Phase 3 review checkpoint. Verify the `alknet-client` crate is spec-conformant,
self-contained, and ready for downstream consumption by the assembly layer (hub/worker).
The crate must match the ADR-089/090/091 shape: three dial methods unified on
`&ConnectionCredentials`, pre-built transports via builder methods, `ClientDialError`
with five variants, and optional SOCKS5 proxy support.
### Review Checklist
1. **Crate structure**:
- Module layout matches spec: `error.rs`, `client.rs`, `dial/{mod,quinn,tcp_tls,iroh}.rs`, `socks5.rs`
- Public API types: `AlknetClient`, `ClientDialError`, `Socks5ProxyConfig`, `Socks5Credentials`
- Re-exports in `lib.rs` are correct and minimal
- No dependency on `alknet-call` (dial is below the protocol)
2. **`AlknetClient` API shape (ADR-089/090)**:
- `new()` takes no parameters (no `StaticConfig`, no credentials)
- `with_quinn(endpoint: quinn::Endpoint)` builder (feature-gated on `quinn`)
- `with_tcp_tls(connector: TlsConnector)` builder (feature-gated on `tcp`)
- `with_iroh(endpoint: iroh::Endpoint)` builder (feature-gated on `iroh`)
- `with_socks5_proxy(proxy: Socks5ProxyConfig)` builder (feature-gated on `socks5`)
- `Default` impl delegates to `new()`
- `Debug` impl lists configured transports (no transport internals)
- No `connect()` method (the old welded dial is not replicated)
3. **`ClientDialError` (ADR-089)**:
- Five variants: `TlsConfig`, `Connect`, `Handshake`, `NoTransport`, `Proxy`
- `TlsConfig` wraps `alknet_tls::TlsError` via `#[from]`
- `Connect` and `Handshake` take `String` (not concrete transport error types)
- `NoTransport` has `transport: &'static str` field
- `Proxy` is feature-gated on `socks5`
- `#[non_exhaustive]` attribute
- All variants have descriptive `#[error("...")]` messages
4. **`dial_quic` (ADR-089 §3, ADR-091)**:
- Signature: `(addr, server_name, alpn, creds: &ConnectionCredentials) -> Result<Connection, ClientDialError>`
- Builds `TlsClientConfig::new(creds, alpn)` — `TlsError` → `ClientDialError::TlsConfig`
- Converts to `quinn::ClientConfig` via `for_quinn()`
- Uses pre-built quinn endpoint from `self.quinn`
- Returns `NoTransport` when `self.quinn` is `None`
- Returns `Connection::from_quinn_with_alpn(conn, alpn)`
- Does NOT call `spawn_dispatch` (protocol take-over is caller's concern)
- Does NOT hardcode `alknet/call` ALPN
- SOCKS5 proxy path: uses `Socks5UdpSocket` + `new_with_abstract_socket` when proxy configured
5. **`dial_tcp_tls` (ADR-089 §3, ADR-091)**:
- Signature: `(host, addr, alpn, creds: &ConnectionCredentials) -> Result<Connection, ClientDialError>`
- Builds `TlsClientConfig::new(creds, alpn)`
- Uses pre-built `TlsConnector` or builds one from `TlsClientConfig`
- Connects TCP via `TcpStream::connect(addr)`
- `Connect` errors → `ClientDialError::Connect`
- TLS handshake errors → `ClientDialError::Handshake`
- Returns `Connection::from_bidi(tls_stream, alpn, Some(addr))`
- SOCKS5 proxy path: SOCKS5 CONNECT handshake before TLS
6. **`dial_iroh` (ADR-089 §3, ADR-091)**:
- Signature: `(alpn, creds: &ConnectionCredentials) -> Result<Connection, ClientDialError>`
- Does NOT use `TlsClientConfig` (iroh has its own TLS)
- Extracts `NodeId` from `creds.remote_identity.fingerprint`
- Unknown iroh remote (`remote_identity: None`) fails closed
- Returns `Connection::from_iroh(conn)`
- No `addr` or `server_name` parameter (iroh handles addressing internally)
7. **SOCKS5 proxy (ADR-090)**:
- `Socks5ProxyConfig` with `addr` and `credentials` fields
- `Socks5Credentials` with `username` and `password` fields
- `Socks5UdpSocket` implements `quinn::AsyncUdpSocket`
- `dial_quic` routes through UDP ASSOCIATE when proxy configured
- `dial_tcp_tls` routes through CONNECT when proxy configured
- No silent fallback to direct connection when proxy configured
- `Proxy` error variant used for proxy failures
- Feature-gated on `socks5`
8. **Dependency hygiene**:
- `alknet-core` and `alknet-tls` are the only alknet dependencies
- No dependency on `alknet-call` or `alknet-channels-call`
- `quinn` is optional, gated behind `quinn` feature (pulls `alknet-tls/quinn` + `alknet-core/quinn`)
- `tokio-rustls` is optional, gated behind `tcp` feature (pulls `alknet-tls/tcp`)
- `iroh` is optional, gated behind `iroh` feature (pulls `alknet-core/iroh`)
- `fast-socks5` is optional, gated behind `socks5` feature
- No unexpected heavy deps
9. **Test coverage**:
- `AlknetClient` construction tests: `new()`, `Default`, `Send + Sync`, `Debug`
- `ClientDialError` tests: `#[from]` conversion, display formatting, `Send + Sync`
- `dial_quic` error path tests: `NoTransport`, `TlsConfig`
- `dial_tcp_tls` error path tests: `NoTransport`
- `dial_iroh` error path tests: `NoTransport`, unknown remote fail-closed
- `Socks5ProxyConfig` / `Socks5Credentials` construction tests
- Integration test: `tests/dial_and_takeover.rs`
- Feature-gated tests are correctly annotated
10. **Cross-cutting checks**:
- `cargo build -p alknet-client` succeeds (all feature combos)
- `cargo test -p alknet-client` succeeds (all feature combos)
- `cargo clippy -p alknet-client --all-targets` succeeds with no warnings
- `cargo fmt --check -p alknet-client` passes
- `cargo build --workspace` still succeeds (old code untouched)
- `cargo test --workspace` still succeeds (old tests untouched)
## Acceptance Criteria
- [ ] Crate structure matches spec (7 source files, correct module layout)
- [ ] `AlknetClient` API matches ADR-089/090 shape (builder methods, no `connect()`, no `StaticConfig`)
- [ ] `ClientDialError` has 5 variants, `#[non_exhaustive]`, correct `#[from]` impl
- [ ] `dial_quic` takes `&ConnectionCredentials`, returns `Connection`, ALPN is a parameter
- [ ] `dial_tcp_tls` takes `&ConnectionCredentials`, returns `Connection`, `host` + `addr` separate
- [ ] `dial_iroh` takes `&ConnectionCredentials`, returns `Connection`, no `addr`/`server_name`
- [ ] All three dials unified on `&ConnectionCredentials` (ADR-091)
- [ ] `dial_iroh` does NOT use `TlsClientConfig` (iroh has its own TLS)
- [ ] SOCKS5 proxy: `Socks5ProxyConfig`, `Socks5Credentials`, `Socks5UdpSocket`, proxy integration in dials
- [ ] No silent fallback to direct connection when proxy configured
- [ ] No dependency on `alknet-call`
- [ ] All tests pass (unit + integration)
- [ ] `cargo build -p alknet-client` succeeds (all feature combos)
- [ ] `cargo test -p alknet-client` succeeds (all feature combos)
- [ ] `cargo clippy -p alknet-client --all-targets` succeeds with no warnings
- [ ] `cargo fmt --check -p alknet-client` passes
- [ ] Workspace still green: `cargo build --workspace` + `cargo test --workspace` pass
## References
- docs/research/alknet-crate-extraction/findings.md — Phase 3
- docs/architecture/crates/client/README.md — full architecture spec
- docs/architecture/decisions/089-alknetclient-native-dial-seam.md — ADR-089
- docs/architecture/decisions/090-client-dial-socks5-proxy-seam.md — ADR-090
- docs/architecture/decisions/091-connectioncredentials-decouple-dial-from-call.md — ADR-091
- tasks/client/crate-init.md
- tasks/client/error-type.md
- tasks/client/client-core.md
- tasks/client/dial-quic.md
- tasks/client/dial-tcp-tls.md
- tasks/client/dial-iroh.md
- tasks/client/socks5-proxy.md
- tasks/client/tests.md
## Notes
> This review gates Phase 3 completion. The crate must be self-contained and
> spec-conformant before Phase 4 (core prune) begins, since the prune removes
> the old `endpoint.rs` from core and the assembly layer (which consumes
> `alknet-client`) will wire the dial to the protocol take-overs. The old code
> in `call_client.rs` is intentionally still present (duplicated) — the prune
> happens in Phase 5. If deviations are found, document and fix before
> proceeding to Phase 4.
## Summary
> To be filled on completion
+311
View File
@@ -0,0 +1,311 @@
---
id: client/socks5-proxy
name: Implement SOCKS5 proxy support — Socks5ProxyConfig, Socks5Credentials, Socks5UdpSocket, and proxy integration in dial_quic/dial_tcp_tls
status: pending
depends_on: [client/dial-quic, client/dial-tcp-tls]
scope: broad
risk: high
impact: component
level: implementation
---
## Description
Phase 3, Task 7. Implement the SOCKS5 proxy support for `AlknetClient` per ADR-090.
This is the privacy posture: when a proxy is configured via `with_socks5_proxy`, the
rustls dials route their transport through the proxy — the hub sees the proxy's IP,
not the client's.
This task has four parts:
1. `Socks5ProxyConfig` and `Socks5Credentials` types in `src/socks5.rs`
2. `Socks5UdpSocket` — a `quinn::AsyncUdpSocket` impl that tunnels QUIC datagrams through SOCKS5 UDP ASSOCIATE
3. Proxy integration in `dial_quic` (UDP ASSOCIATE path)
4. Proxy integration in `dial_tcp_tls` (CONNECT path)
The iroh proxy path (force relay-only + HTTP-to-SOCKS5 bridge) is deferred — it's applied
at endpoint construction time by the assembly layer, not at dial time (ADR-090 §5).
### Part 1: `Socks5ProxyConfig` and `Socks5Credentials`
```rust
// src/socks5.rs
/// Configuration for a SOCKS5 proxy (ADR-090).
///
/// When set on `AlknetClient` via `with_socks5_proxy`, all rustls dials
/// route their transport through this proxy: UDP ASSOCIATE for `dial_quic`,
/// CONNECT for `dial_tcp_tls`. The proxy config comes from `Capabilities` /
/// the assembly layer (ADR-014), never from environment variables.
#[derive(Debug, Clone)]
pub struct Socks5ProxyConfig {
/// The proxy's TCP address (where the SOCKS5 control connection
/// connects). For UDP ASSOCIATE (the QUIC dial), the proxy replies
/// with a UDP relay address that may differ; the dial uses that.
pub addr: SocketAddr,
/// Optional username/password auth (RFC 1929). None = no-auth.
pub credentials: Option<Socks5Credentials>,
}
/// SOCKS5 username/password credentials (RFC 1929).
#[derive(Debug, Clone)]
pub struct Socks5Credentials {
pub username: String,
pub password: String,
}
```
### Part 2: `Socks5UdpSocket` — QUIC over SOCKS5 UDP ASSOCIATE
The central technical piece: a `quinn::AsyncUdpSocket` implementation that tunnels
QUIC datagrams through a SOCKS5 UDP ASSOCIATE tunnel. This is the integration glue
between quinn's socket abstraction and the SOCKS5 proxy.
The implementation (~250 lines) follows the pattern validated by the quinn-proxy PoC
(`docs/research/quinn-quic-proxy/findings.md`):
```rust
#[cfg(feature = "socks5")]
struct Socks5UdpSocket {
// The UDP socket to the proxy's relay address
socket: tokio::net::UdpSocket,
// The proxy's UDP relay address (where datagrams are sent)
relay_addr: SocketAddr,
// Keep the TCP control connection alive (dropping it tears down the UDP association)
_control: tokio::net::TcpStream,
}
#[cfg(feature = "socks5")]
impl Socks5UdpSocket {
/// Perform the SOCKS5 UDP ASSOCIATE handshake and return a socket
/// that tunnels QUIC datagrams through the proxy.
async fn bind(proxy: &Socks5ProxyConfig) -> Result<Self, ClientDialError> {
// 1. TCP control connection to proxy.addr
// 2. SOCKS5 handshake (no-auth or username/password per RFC 1929)
// 3. UDP ASSOCIATE request (CMD = 0x03)
// 4. Receive the proxy's UDP relay address
// 5. Bind a local UDP socket
// 6. Return Socks5UdpSocket with the relay address
}
}
#[cfg(feature = "socks5")]
impl quinn::AsyncUdpSocket for Socks5UdpSocket {
fn poll_send(
&self,
state: &quinn::udp::UdpState,
cx: &mut std::task::Context,
transmits: &[quinn::udp::Transmit],
) -> std::task::Poll<Result<usize, io::Error>> {
// For each transmit:
// 1. Prepend the SOCKS5 UDP header (RSV + FRAG + ATYP + DST.ADDR + DST.PORT)
// 2. Send the wrapped datagram to the proxy's relay address
}
fn poll_recv(
&self,
cx: &mut std::task::Context,
bufs: &[std::io::IoSliceMut],
meta: &[quinn::udp::RecvMeta],
) -> std::task::Poll<io::Result<usize>> {
// 1. Receive a datagram from the proxy
// 2. Strip the SOCKS5 UDP header
// 3. Fill in RecvMeta (addr, len, stride)
}
fn local_addr(&self) -> io::Result<SocketAddr> {
self.socket.local_addr()
}
fn may_fragment(&self) -> bool {
false // SOCKS5 UDP has no fragmentation support
}
}
```
**Key limitations (accepted, documented):**
- **ECN is lost.** The SOCKS5 UDP header carries no ECN bits, so the proxied QUIC
path falls back to non-ECN congestion control. A performance cost on congested
links, not a correctness issue.
- **`may_fragment() == false`.** SOCKS5 UDP has no fragmentation mechanism.
- **The proxy must support UDP ASSOCIATE.** `ssh -D` does not work; a UDP-capable
SOCKS5 daemon is needed. The dial surfaces a clear error when the proxy lacks
UDP support.
### Part 3: Proxy integration in `dial_quic`
When `self.socks5` is `Some`, `dial_quic` does NOT use the pre-built quinn endpoint.
Instead, it:
1. Builds the `TlsClientConfig` and `quinn::ClientConfig` as usual
2. Creates a `Socks5UdpSocket` via `Socks5UdpSocket::bind(&proxy)`
3. Builds a temporary quinn endpoint via `quinn::Endpoint::new_with_abstract_socket`
4. Connects through that endpoint
```rust
#[cfg(feature = "quinn")]
pub async fn dial_quic(...) -> Result<Connection, ClientDialError> {
let tls_config = TlsClientConfig::new(creds, alpn)?;
let client_config = tls_config.for_quinn()?;
let conn = if let Some(proxy) = &self.socks5 {
#[cfg(feature = "socks5")]
{
let socket = Socks5UdpSocket::bind(proxy).await?;
let mut endpoint = quinn::Endpoint::new_with_abstract_socket(
quinn::EndpointConfig::default(),
Some(socket.local_addr()?),
socket,
Arc::new(quinn::TokioRuntime),
).map_err(|e| ClientDialError::Connect(e.to_string()))?;
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()))?
}
#[cfg(not(feature = "socks5"))]
unreachable!()
} else {
// Direct path (implemented in client/dial-quic)
let endpoint = self.quinn.as_ref()
.ok_or(ClientDialError::NoTransport { transport: "quinn" })?;
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()))?
};
Ok(Connection::from_quinn_with_alpn(conn, alpn.to_vec()))
}
```
### Part 4: Proxy integration in `dial_tcp_tls`
When `self.socks5` is `Some`, `dial_tcp_tls`:
1. Connects a `TcpStream` to the proxy's address
2. Performs the SOCKS5 CONNECT handshake (RFC 1928 §3) to the target `addr`
3. Wraps the resulting stream in `TlsConnector` as before
```rust
#[cfg(feature = "tcp")]
pub async fn dial_tcp_tls(...) -> Result<Connection, ClientDialError> {
let tls_config = TlsClientConfig::new(creds, alpn)?;
let connector = /* build or use pre-built TlsConnector */;
let tls_stream = if let Some(proxy) = &self.socks5 {
#[cfg(feature = "socks5")]
{
// 1. Connect to proxy
let mut tcp = TcpStream::connect(proxy.addr)
.await
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
// 2. SOCKS5 CONNECT handshake to target addr
socks5_connect(&mut tcp, proxy, addr)
.await
.map_err(|e| ClientDialError::Proxy(e))?;
// 3. TLS over proxied stream
let server_name = rustls::pki_types::ServerName::try_from(host)
.map_err(|e| ClientDialError::Connect(e.to_string()))?;
connector
.connect(server_name, tcp)
.await
.map_err(|e| ClientDialError::Handshake(e.to_string()))?
}
#[cfg(not(feature = "socks5"))]
unreachable!()
} else {
// Direct path (implemented in client/dial-tcp-tls)
// ...
};
Ok(Connection::from_bidi(tls_stream, alpn.to_vec(), Some(addr)))
}
```
### SOCKS5 CONNECT helper
```rust
#[cfg(feature = "socks5")]
async fn socks5_connect(
stream: &mut tokio::net::TcpStream,
proxy: &Socks5ProxyConfig,
target: SocketAddr,
) -> Result<(), String> {
// 1. Greeting: send version + auth methods
// 2. Auth: no-auth or username/password (RFC 1929)
// 3. CONNECT request: CMD=0x01, ATYP=0x01 (IPv4) or 0x03 (domain), DST.ADDR, DST.PORT
// 4. Read reply: version, REP (0x00 = success), ATYP, BND.ADDR, BND.PORT
// 5. If REP != 0x00, return error with the REP code
}
```
### What this does NOT include
- **iroh proxy path** (force relay-only + HTTP-to-SOCKS5 bridge): Applied at endpoint
construction time by the assembly layer (ADR-090 §5), not at dial time. The iroh
endpoint is built with `clear_ip_transports()` + `addr_filter(relay_only)` +
`proxy_url` by the assembly layer when a proxy is configured. The dial itself
doesn't change.
- **No silent fallback**: When a proxy is configured and the proxy rejects the
command, the dial returns `ClientDialError::Proxy`. The dial does not silently
fall back to a direct connection — that would defeat the privacy posture.
- **No HTTP CONNECT support**: SOCKS5 only. SOCKS5 supports both TCP (CONNECT) and
UDP (UDP ASSOCIATE) within one protocol. HTTP CONNECT is TCP-only with no UDP
equivalent.
### Dependency: `fast-socks5`
The `fast-socks5` crate (v1.0.0) is used for the SOCKS5 client handshake. The
client-side surface used is small (`Socks5Datagram::bind` / `bind_with_password`,
`new_udp_header`, the header parsing). Whether to keep `fast-socks5` as a dep or
vendor the ~100 lines of SOCKS5 handshake + header framing is a two-way-door
implementation detail — this task uses `fast-socks5` as the starting choice
(validated by the PoC).
## Acceptance Criteria
- [ ] `Socks5ProxyConfig` struct defined in `crates/alknet-client/src/socks5.rs` with `addr` and `credentials` fields
- [ ] `Socks5Credentials` struct defined with `username` and `password` fields
- [ ] Both types derive `Debug`, `Clone`
- [ ] Both types feature-gated on `#[cfg(feature = "socks5")]`
- [ ] `Socks5UdpSocket` implements `quinn::AsyncUdpSocket` (feature-gated on `socks5`)
- [ ] `Socks5UdpSocket::bind(proxy)` performs SOCKS5 UDP ASSOCIATE handshake
- [ ] `Socks5UdpSocket::poll_send` prepends SOCKS5 UDP header to each datagram
- [ ] `Socks5UdpSocket::poll_recv` strips SOCKS5 UDP header from received datagrams
- [ ] `Socks5UdpSocket::may_fragment()` returns `false`
- [ ] `dial_quic` uses `Socks5UdpSocket` + `new_with_abstract_socket` when proxy is configured
- [ ] `dial_quic` returns `Proxy` error when UDP ASSOCIATE fails
- [ ] `dial_tcp_tls` performs SOCKS5 CONNECT handshake when proxy is configured
- [ ] `dial_tcp_tls` returns `Proxy` error when CONNECT fails
- [ ] No silent fallback to direct connection when proxy is configured
- [ ] `cargo check -p alknet-client --features quinn,socks5` succeeds
- [ ] `cargo check -p alknet-client --features tcp,socks5` succeeds
- [ ] `cargo clippy -p alknet-client --features quinn,tcp,socks5` succeeds with no warnings
- [ ] `cargo build --workspace` still succeeds (old code untouched)
## References
- docs/architecture/crates/client/README.md — SOCKS5 proxy section (lines 238-325)
- docs/architecture/decisions/090-client-dial-socks5-proxy-seam.md — ADR-090 (full rationale, PoC grounding, limitations)
- docs/research/quinn-quic-proxy/findings.md — quinn-over-SOCKS5 PoC findings
- docs/research/iroh-proxy-poc/findings.md — iroh-proxy PoC findings (iroh path is deferred)
- crates/alknet-tls/src/client.rs — `TlsClientConfig` (the TLS config the dial consumes)
- crates/alknet-core/src/credentials.rs — `ConnectionCredentials` (the credential bundle)
- RFC 1928 (SOCKS5) §3 (CONNECT), §6/§7 (UDP ASSOCIATE + UDP request header)
- RFC 1929 (SOCKS5 username/password auth)
## Notes
> This is the most complex task in Phase 3 — the `Socks5UdpSocket` is ~250 lines of
> integration glue between quinn's `AsyncUdpSocket` trait and the SOCKS5 UDP ASSOCIATE
> protocol. The implementation is grounded in the quinn-proxy PoC
> (`docs/research/quinn-quic-proxy/findings.md`), which validated the approach
> end-to-end (5/5 runs clean). The `fast-socks5` crate handles the SOCKS5 handshake;
> the `Socks5UdpSocket` is alknet's own code. The iroh proxy path (force relay-only +
> HTTP-to-SOCKS5 bridge) is deferred — it's applied at endpoint construction time by
> the assembly layer, not at dial time. The `socks5` feature and `fast-socks5` dep
> are opt-in; deployments that don't use a proxy pay nothing.
## Summary
> To be filled on completion
+299
View File
@@ -0,0 +1,299 @@
---
id: client/tests
name: Write unit tests for AlknetClient, dial methods, error type, and SOCKS5 proxy; add integration test for dial + take-over composition
status: pending
depends_on: [client/dial-quic, client/dial-tcp-tls, client/dial-iroh, client/socks5-proxy]
scope: moderate
risk: medium
impact: component
level: implementation
---
## Description
Phase 3, Task 8. Write unit tests for the `alknet-client` crate and add the integration
test for the dial + take-over composition. The tests cover:
1. **`AlknetClient` construction and builder methods** — `new()`, `with_quinn`, `with_tcp_tls`, `with_iroh`, `with_socks5_proxy`, `Default`
2. **`ClientDialError`** — display formatting, `#[from]` conversion from `TlsError`
3. **`dial_quic`** — direct path (no proxy), `NoTransport` error, `TlsConfig` error
4. **`dial_tcp_tls`** — direct path (no proxy), `NoTransport` error, `TlsConfig` error
5. **`dial_iroh`** — direct path, `NoTransport` error, unknown remote fail-closed
6. **SOCKS5 proxy** — `Socks5ProxyConfig` construction, `Socks5Credentials` construction
7. **Integration test** — dial + take-over composition (moved from `alknet-call/tests/two_node_call.rs`)
### Test strategy
Since the dial methods produce real network connections, the unit tests focus on:
- **Error paths**: `NoTransport` (calling a dial without the matching `with_*`),
`TlsConfig` (invalid credentials), unknown iroh remote fail-closed
- **Builder correctness**: fields are set correctly, `Default` works
- **Type-level tests**: `Send + Sync` bounds, `Debug` output
The integration test (dial + take-over composition) uses a loopback `Connection` or
a minimal echo `ProtocolHandler` on a test ALPN — no `alknet-call` dependency.
### Test outline
#### 1. `AlknetClient` construction tests (in `src/client.rs`)
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_creates_empty_client() {
let client = AlknetClient::new();
// All transports are None
}
#[test]
fn default_delegates_to_new() {
let client = AlknetClient::default();
// Same as new()
}
#[test]
fn alknet_client_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<AlknetClient>();
}
#[test]
fn debug_lists_configured_transports() {
let client = AlknetClient::new();
let debug = format!("{:?}", client);
// Does not panic, does not expose transport internals
}
}
```
#### 2. `ClientDialError` tests (in `src/error.rs`)
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tls_config_from_tls_error() {
let err = alknet_tls::TlsError::Config("test".into());
let dial_err: ClientDialError = err.into();
assert!(matches!(dial_err, ClientDialError::TlsConfig(_)));
}
#[test]
fn no_transport_displays_transport_name() {
let err = ClientDialError::NoTransport { transport: "quinn" };
assert!(err.to_string().contains("quinn"));
}
#[test]
fn connect_displays_message() {
let err = ClientDialError::Connect("connection refused".into());
assert!(err.to_string().contains("connection refused"));
}
#[test]
fn handshake_displays_message() {
let err = ClientDialError::Handshake("certificate rejected".into());
assert!(err.to_string().contains("certificate rejected"));
}
#[cfg(feature = "socks5")]
#[test]
fn proxy_displays_message() {
let err = ClientDialError::Proxy("UDP ASSOCIATE rejected".into());
assert!(err.to_string().contains("UDP ASSOCIATE rejected"));
}
#[test]
fn client_dial_error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ClientDialError>();
}
}
```
#### 3. `dial_quic` error path tests (in `src/dial/quinn.rs`)
```rust
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn dial_quic_no_transport_error() {
let client = AlknetClient::new();
let creds = ConnectionCredentials::new();
let result = client.dial_quic(
"127.0.0.1:0".parse().unwrap(),
"localhost",
b"test/alpn",
&creds,
).await;
assert!(matches!(result, Err(ClientDialError::NoTransport { .. })));
}
#[tokio::test]
async fn dial_quic_tls_config_error_on_invalid_creds() {
// Test that invalid credentials produce TlsConfig error
// (e.g., ACME identity on client side)
}
}
```
#### 4. `dial_tcp_tls` error path tests (in `src/dial/tcp_tls.rs`)
```rust
#[cfg(test)]
mod tests {
#[tokio::test]
async fn dial_tcp_tls_no_transport_error() {
// Similar to dial_quic — calling without with_tcp_tls
}
}
```
#### 5. `dial_iroh` error path tests (in `src/dial/iroh.rs`)
```rust
#[cfg(test)]
mod tests {
#[tokio::test]
async fn dial_iroh_no_transport_error() {
// Calling without with_iroh
}
#[tokio::test]
async fn dial_iroh_unknown_remote_fails_closed() {
let client = AlknetClient::new(); // no iroh endpoint needed for this error
let creds = ConnectionCredentials::new(); // remote_identity is None
// This should fail with TlsConfig error before even trying to connect
}
}
```
#### 6. SOCKS5 proxy type tests (in `src/socks5.rs`)
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socks5_proxy_config_construction() {
let proxy = Socks5ProxyConfig {
addr: "127.0.0.1:1080".parse().unwrap(),
credentials: None,
};
assert_eq!(proxy.addr.port(), 1080);
assert!(proxy.credentials.is_none());
}
#[test]
fn socks5_proxy_config_with_auth() {
let proxy = Socks5ProxyConfig {
addr: "127.0.0.1:1080".parse().unwrap(),
credentials: Some(Socks5Credentials {
username: "user".into(),
password: "pass".into(),
}),
};
assert!(proxy.credentials.is_some());
}
#[test]
fn socks5_proxy_config_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Socks5ProxyConfig>();
}
}
```
#### 7. Integration test (in `tests/dial_and_takeover.rs`)
The integration test from `alknet-call/tests/two_node_call.rs` (`two_node_call_round_trip`)
moves here, rewritten with a minimal echo `ProtocolHandler` on a test ALPN — no
`alknet-call` dependency:
```rust
// tests/dial_and_takeover.rs
//
// Integration test: dial + take-over composition.
// Uses a loopback Connection (from_stream) to test that dial_quic
// produces a Connection that a protocol take-over can consume.
use alknet_client::AlknetClient;
use alknet_core::credentials::ConnectionCredentials;
use alknet_core::types::Connection;
#[tokio::test]
async fn dial_produces_connection_for_takeover() {
// This test verifies the composition: dial → Connection → take-over.
// Since we can't spin up a real QUIC endpoint in a unit test,
// we test the error paths and type-level contracts.
// The full end-to-end dial + take-over is tested in the assembly
// layer integration tests (future hub/worker tests).
// For now: verify that the dial methods exist, compile, and
// produce the correct error types.
let client = AlknetClient::new();
let creds = ConnectionCredentials::new();
// No transport configured → NoTransport error
let result = client.dial_quic(
"127.0.0.1:0".parse().unwrap(),
"localhost",
b"test/alpn",
&creds,
).await;
assert!(result.is_err());
}
```
### What this does NOT include
- Full end-to-end QUIC dial tests (require a real quinn endpoint — tested in the
assembly layer integration tests)
- Tests for the old `CallClient::connect` (that code is unchanged — Phase 5 prune)
- Tests that require a running SOCKS5 proxy (the `Socks5UdpSocket` is tested via
the PoC; unit tests cover type-level contracts)
## Acceptance Criteria
- [ ] `AlknetClient` construction tests: `new()`, `Default`, `Send + Sync`, `Debug`
- [ ] `ClientDialError` tests: `#[from]` conversion, display formatting, `Send + Sync`
- [ ] `dial_quic` error path tests: `NoTransport`, `TlsConfig` on invalid creds
- [ ] `dial_tcp_tls` error path tests: `NoTransport`
- [ ] `dial_iroh` error path tests: `NoTransport`, unknown remote fail-closed
- [ ] `Socks5ProxyConfig` / `Socks5Credentials` construction tests (feature-gated on `socks5`)
- [ ] Integration test: `tests/dial_and_takeover.rs` (dial + take-over composition)
- [ ] All tests pass: `cargo test -p alknet-client`
- [ ] All tests pass with feature combos: `cargo test -p alknet-client --features quinn`, `--features tcp`, `--features iroh`, `--features quinn,tcp,socks5`
- [ ] `cargo clippy -p alknet-client --all-targets` succeeds with no warnings
- [ ] `cargo fmt --check -p alknet-client` passes
- [ ] `cargo test --workspace` still passes (old tests untouched)
## References
- docs/research/alknet-crate-extraction/findings.md — Phase 3, test strategy
- docs/architecture/crates/client/README.md — full architecture spec
- crates/alknet-call/tests/two_node_call.rs — old integration test (reference for the dial + take-over composition)
- crates/alknet-call/src/client/call_client.rs — old test patterns (lines 640-930, reference)
- crates/alknet-tls/src/client.rs — `TlsClientConfig` tests (reference for test patterns)
- crates/alknet-endpoint/src/endpoint.rs — endpoint tests (reference for test patterns)
## Notes
> The test strategy focuses on error paths and type-level contracts because the dial
> methods produce real network connections. Full end-to-end dial tests (with a real
> quinn endpoint) are tested in the assembly layer integration tests (future hub/worker
> tests). The integration test from `alknet-call/tests/two_node_call.rs` moves here,
> rewritten with a minimal echo `ProtocolHandler` on a test ALPN — no `alknet-call`
> dependency. The old code in `call_client.rs` is NOT deleted — that's Phase 5.
## Summary
> To be filled on completion