From b476182d643c9cd789e7d55633de9f57dd3e9025 Mon Sep 17 00:00:00 2001 From: deepseek-v4-pro Date: Fri, 17 Jul 2026 12:07:08 +0000 Subject: [PATCH] feat(tasks): decompose Phase 3 alknet-client into 9 atomic tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tasks/client/client-core.md | 197 +++++++++++++++++++++ tasks/client/crate-init.md | 155 +++++++++++++++++ tasks/client/dial-iroh.md | 202 ++++++++++++++++++++++ tasks/client/dial-quic.md | 192 +++++++++++++++++++++ tasks/client/dial-tcp-tls.md | 169 ++++++++++++++++++ tasks/client/error-type.md | 132 +++++++++++++++ tasks/client/review-client.md | 161 ++++++++++++++++++ tasks/client/socks5-proxy.md | 311 ++++++++++++++++++++++++++++++++++ tasks/client/tests.md | 299 ++++++++++++++++++++++++++++++++ 9 files changed, 1818 insertions(+) create mode 100644 tasks/client/client-core.md create mode 100644 tasks/client/crate-init.md create mode 100644 tasks/client/dial-iroh.md create mode 100644 tasks/client/dial-quic.md create mode 100644 tasks/client/dial-tcp-tls.md create mode 100644 tasks/client/error-type.md create mode 100644 tasks/client/review-client.md create mode 100644 tasks/client/socks5-proxy.md create mode 100644 tasks/client/tests.md diff --git a/tasks/client/client-core.md b/tasks/client/client-core.md new file mode 100644 index 0000000..1f5a814 --- /dev/null +++ b/tasks/client/client-core.md @@ -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, + #[cfg(feature = "tcp")] + tcp_connector: Option, + #[cfg(feature = "iroh")] + iroh: Option, + /// 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, +} + +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 diff --git a/tasks/client/crate-init.md b/tasks/client/crate-init.md new file mode 100644 index 0000000..1dc45ef --- /dev/null +++ b/tasks/client/crate-init.md @@ -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 diff --git a/tasks/client/dial-iroh.md b/tasks/client/dial-iroh.md new file mode 100644 index 0000000..c0cc65c --- /dev/null +++ b/tasks/client/dial-iroh.md @@ -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:` → `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; +} +``` + +### Implementation outline + +```rust +#[cfg(feature = "iroh")] +pub async fn dial_iroh( + &self, + alpn: &[u8], + creds: &ConnectionCredentials, +) -> Result { + // 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:" or "SHA256:" + // 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:"` — raw Ed25519 public key (64 hex chars) +/// - `"SHA256:"` — 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 { + 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` +- [ ] 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:` 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 diff --git a/tasks/client/dial-quic.md b/tasks/client/dial-quic.md new file mode 100644 index 0000000..e5b2b87 --- /dev/null +++ b/tasks/client/dial-quic.md @@ -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; +} +``` + +### Implementation outline + +```rust +#[cfg(feature = "quinn")] +pub async fn dial_quic( + &self, + addr: SocketAddr, + server_name: &str, + alpn: &[u8], + creds: &ConnectionCredentials, +) -> Result { + // 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 +{ + 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` +- [ ] 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 diff --git a/tasks/client/dial-tcp-tls.md b/tasks/client/dial-tcp-tls.md new file mode 100644 index 0000000..34e6589 --- /dev/null +++ b/tasks/client/dial-tcp-tls.md @@ -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; +} +``` + +### Implementation outline + +```rust +#[cfg(feature = "tcp")] +pub async fn dial_tcp_tls( + &self, + host: &str, + addr: SocketAddr, + alpn: &[u8], + creds: &ConnectionCredentials, +) -> Result { + // 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` +- [ ] 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 diff --git a/tasks/client/error-type.md b/tasks/client/error-type.md new file mode 100644 index 0000000..f6cae7e --- /dev/null +++ b/tasks/client/error-type.md @@ -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` 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 diff --git a/tasks/client/review-client.md b/tasks/client/review-client.md new file mode 100644 index 0000000..c0f6710 --- /dev/null +++ b/tasks/client/review-client.md @@ -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` + - 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` + - 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` + - 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 diff --git a/tasks/client/socks5-proxy.md b/tasks/client/socks5-proxy.md new file mode 100644 index 0000000..12ffc81 --- /dev/null +++ b/tasks/client/socks5-proxy.md @@ -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, +} + +/// 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 { + // 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> { + // 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> { + // 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 { + 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 { + 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 { + 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 diff --git a/tasks/client/tests.md b/tasks/client/tests.md new file mode 100644 index 0000000..0ac495d --- /dev/null +++ b/tasks/client/tests.md @@ -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() {} + assert_send_sync::(); + } + + #[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() {} + assert_send_sync::(); + } +} +``` + +#### 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() {} + assert_send_sync::(); + } +} +``` + +#### 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