Files
alknet/tasks/client/dial-quic.md
T
deepseek-v4-pro 83a94ec03f chore(client): mark all client tasks as completed
Phase 3 complete: alknet-client crate implemented with:
- AlknetClient struct with builder methods (with_quinn, with_tcp_tls, with_iroh, with_socks5_proxy)
- ClientDialError enum with 5 variants
- dial_quic, dial_tcp_tls, dial_iroh methods
- SOCKS5 proxy support (Socks5ProxyConfig, Socks5Credentials, Socks5UdpSocket)
- Unit tests (18) + integration test (3)
- All feature combos compile and test clean
- Workspace build and tests pass
2026-07-17 12:49:50 +00:00

193 lines
8.5 KiB
Markdown

---
id: client/dial-quic
name: Implement dial_quic — QUIC dial via quinn, producing a Connection
status: completed
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