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:
1 parent
ab56acae69
commit
b476182d64
9 files changed
+1818
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user