feat(call): CallClient + shared dispatch loop + peer-scoped default-deny (ADR-017, ADR-028)
The #1 gap in alknet-call: the outbound connection opener. Every downstream consumer (runner, container service, bilateral exchange, NAPI, agent cross-node dispatch) is blocked on it. Shared dispatch loop (ADR-017 §1 — the architectural commitment that keeps CallClient from becoming a parallel protocol implementation): - Extracts the accept-path dispatch (sweeper, accept_bi loop, handle_stream, dispatch_requested, build_root_context, compose_root_env, fail_all on close) out of CallAdapter into a new protocol/dispatch.rs Dispatcher struct. Both CallAdapter::handle and CallClient::connect produce a CallConnection and hand it to Dispatcher::run_loop — the loop is genuinely shared (refactored, not duplicated). - CallAdapter keeps its public API and test-facing wrappers (pub(crate), #[cfg(test)]-gated) that delegate to the Dispatcher. Peer-scoped default-deny (ADR-028 — the one-way-door security dimension): - RemoteFilter { trusted_peer: bool } on the Dispatcher. In default-deny mode (CallClient::new), an incoming call to an op with remote_safe: false returns NOT_FOUND *before* any capability material reaches the handler — a remote peer's call must not populate OperationContext.capabilities from the local registration bundle unless the op is explicitly remote-safe (ADR-028 Context). Trusted-peer mode (CallClient::trusted_peer, explicit opt-in) bypasses the filter. - The accept path (CallAdapter) uses RemoteFilter::trusted() by convention: a direct QUIC client is not a filtered CallClient peer in the ADR-028 sense. - OperationRegistry::list_operations_peer_scoped(trusted_peer) + services_list_handler_peer_scoped for the CallClient's services/list serving path (ADR-028 Assumption 2: a peer should not see ops it cannot call, so discovery and dispatch filters agree). CallClient (src/client/call_client.rs): - CallClient { registry, identity_provider, trusted_peer: bool }. - new() default-deny; trusted_peer() explicit opt-in (ADR-028 §3). - connect(addr, CallCredentials) dials QUIC on ALPN alknet/call (quinn feature), spawns Dispatcher::run_loop, returns a live CallConnection. - spawn_dispatch(connection) shared path for connect + tests. - CallCredentials { tls_identity, auth_token, remote_identity } — all from Capabilities (ADR-014), never env vars (no-env-vars invariant). v1 connects without client-auth TLS identity (server uses AcceptAnyCertVerifier); RawKey client-auth is a two-way-door remainder. - RemoteIdentity { fingerprint } — concrete shape is a two-way door (OQ-25 remainder); the one-way constraint is it comes from Capabilities. - ClientError { Transport, TlsSetup, ConnectionClosed }. - CallConnection is now Clone (shares the inner Arcs) so connect can hand the caller a live clone while the dispatcher task keeps its clone. Tests (199 lib + 1 integration): - Unit: default-deny NOT_FOUND for non-remote-safe; remote_safe dispatches; trusted-peer dispatches all External; default-deny does NOT populate capabilities (the load-bearing security assertion — verified by a handler that inspects context.capabilities and the fact that the handler is never reached for non-remote-safe ops); remote_safe op populates capabilities; services/list peer-scoped hide/trusted variants; CallClient constructors; CallCredentials builder; Send+Sync. - Integration (tests/two_node_call.rs): real QUIC loopback — CallAdapter server (self-signed cert via rcgen) accepts, CallClient connects, client.call() round-trips to server/echo. Proves the connect path + shared dispatch loop work end-to-end. clippy + fmt + test all green. Refs: tasks/call/client/call-client.md Refs: docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md §1, §2, §7 Refs: docs/architecture/decisions/028-callclient-peer-scoped-registry-filtering.md Refs: docs/architecture/crates/call/client-and-adapters.md
This commit is contained in:
231
crates/alknet-call/tests/two_node_call.rs
Normal file
231
crates/alknet-call/tests/two_node_call.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
//! Integration test: two-node `alknet/call` round-trip over a real QUIC
|
||||
//! loopback. A `CallAdapter` server accepts, a `CallClient` connects, and
|
||||
//! the client calls back into the server (connection symmetry, ADR-017 §2).
|
||||
//! Verifies the shared dispatch loop works end-to-end and that the
|
||||
//! peer-scoped default-deny filter (ADR-028) is enforced over a real
|
||||
//! connection.
|
||||
|
||||
#![cfg(feature = "quinn")]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use alknet_call::client::{CallClient, CallCredentials};
|
||||
use alknet_call::protocol::adapter::CallAdapter;
|
||||
use alknet_call::protocol::wire::ResponseEnvelope;
|
||||
use alknet_call::registry::discovery::{
|
||||
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
||||
};
|
||||
use alknet_call::registry::registration::{
|
||||
make_handler, Handler, HandlerRegistration, OperationProvenance, OperationRegistry,
|
||||
};
|
||||
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::auth::{Identity, IdentityProvider};
|
||||
use alknet_core::types::{Capabilities, Connection, ProtocolHandler};
|
||||
|
||||
struct NoopIdentityProvider;
|
||||
impl IdentityProvider for NoopIdentityProvider {
|
||||
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, _: &alknet_core::auth::AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn external_spec(name: &str) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
OperationType::Query,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
AccessControl::default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn echo_handler() -> Handler {
|
||||
make_handler(|input, context| async move { ResponseEnvelope::ok(context.request_id, input) })
|
||||
}
|
||||
|
||||
/// Build a raw quinn server endpoint with a self-signed cert and the
|
||||
/// `CallAdapter` accepting `alknet/call` connections. Returns
|
||||
/// `(bound_addr, join_handle)`. The accept loop spawns a task per connection
|
||||
/// that hands the connection to `CallAdapter::handle`.
|
||||
async fn build_raw_quinn_server(
|
||||
registry: Arc<OperationRegistry>,
|
||||
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdentityProvider);
|
||||
let adapter = Arc::new(CallAdapter::new(
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&provider),
|
||||
));
|
||||
|
||||
let key_pair = rcgen::KeyPair::generate().expect("key gen");
|
||||
let params = rcgen::CertificateParams::default();
|
||||
let cert = params.self_signed(&key_pair).expect("self-signed cert");
|
||||
let cert_der = cert.der().clone();
|
||||
let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8(
|
||||
rustls::pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()),
|
||||
);
|
||||
|
||||
let provider_crypto = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||
let mut server_config = rustls::ServerConfig::builder_with_provider(provider_crypto)
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert_der], key_der)
|
||||
.unwrap();
|
||||
server_config.alpn_protocols = vec![b"alknet/call".to_vec()];
|
||||
server_config.max_early_data_size = u32::MAX;
|
||||
|
||||
let quic_server_config =
|
||||
quinn::crypto::rustls::QuicServerConfig::try_from(server_config).unwrap();
|
||||
let quinn_server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_server_config));
|
||||
|
||||
let quinn_endpoint =
|
||||
quinn::Endpoint::server(quinn_server_config, "127.0.0.1:0".parse().unwrap())
|
||||
.expect("server bind");
|
||||
let bound_addr = quinn_endpoint.local_addr().expect("local addr");
|
||||
|
||||
let join = tokio::spawn(async move {
|
||||
while let Some(incoming) = quinn_endpoint.accept().await {
|
||||
let adapter = Arc::clone(&adapter);
|
||||
tokio::spawn(async move {
|
||||
let connecting = match incoming.accept() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
let conn = match connecting.await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
let alpn = b"alknet/call".to_vec();
|
||||
let conn = Connection::from_quinn_with_alpn(conn, alpn.clone());
|
||||
let auth = alknet_core::auth::AuthContext {
|
||||
identity: None,
|
||||
alpn,
|
||||
remote_addr: conn.remote_addr(),
|
||||
tls_client_fingerprint: None,
|
||||
};
|
||||
let _ = adapter.handle(conn, &auth).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
(bound_addr, join)
|
||||
}
|
||||
|
||||
/// Build the server's registry: a remote_safe echo op, a non-remote-safe
|
||||
/// secret op, and the services/list + services/schema discovery handlers.
|
||||
fn build_server_registry() -> Arc<OperationRegistry> {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(
|
||||
HandlerRegistration::new(
|
||||
external_spec("server/echo"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
)
|
||||
.remote_safe(true),
|
||||
);
|
||||
registry.register(HandlerRegistration::new(
|
||||
external_spec("server/secret"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new().with_api_key("google", "server-secret".to_string()),
|
||||
));
|
||||
let discovery_registry = Arc::new(registry);
|
||||
let list_handler = services_list_handler(Arc::clone(&discovery_registry));
|
||||
let schema_handler = services_schema_handler(Arc::clone(&discovery_registry));
|
||||
let mut full = OperationRegistry::new();
|
||||
full.register(
|
||||
HandlerRegistration::new(
|
||||
external_spec("server/echo"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
)
|
||||
.remote_safe(true),
|
||||
);
|
||||
full.register(HandlerRegistration::new(
|
||||
external_spec("server/secret"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new().with_api_key("google", "server-secret".to_string()),
|
||||
));
|
||||
full.register(HandlerRegistration::new(
|
||||
services_list_spec(),
|
||||
list_handler,
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
full.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
schema_handler,
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
));
|
||||
Arc::new(full)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn two_node_call_round_trip() {
|
||||
let server_registry = build_server_registry();
|
||||
let (server_addr, _server_join) = build_raw_quinn_server(Arc::clone(&server_registry)).await;
|
||||
|
||||
// Client side: a CallClient in default-deny mode with its own ops so the
|
||||
// server can call back (connection symmetry).
|
||||
let mut client_registry = OperationRegistry::new();
|
||||
client_registry.register(
|
||||
HandlerRegistration::new(
|
||||
external_spec("client/echo"),
|
||||
echo_handler(),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
)
|
||||
.remote_safe(true),
|
||||
);
|
||||
let client_registry = Arc::new(client_registry);
|
||||
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
|
||||
|
||||
let conn = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
client.connect(server_addr, CallCredentials::new()),
|
||||
)
|
||||
.await
|
||||
.expect("connect did not time out")
|
||||
.expect("connect succeeds");
|
||||
|
||||
// Outbound call: client -> server's remote_safe op.
|
||||
let response = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
conn.call("server/echo", serde_json::json!({"hi": 1})),
|
||||
)
|
||||
.await
|
||||
.expect("call did not time out");
|
||||
assert_eq!(response.result, Ok(serde_json::json!({"hi": 1})));
|
||||
|
||||
// The peer-scoped default-deny behavior (a CallClient hiding its
|
||||
// non-remote-safe ops from a remote peer that calls back) is exercised by
|
||||
// the unit tests in `client/call_client.rs` against the shared
|
||||
// `Dispatcher`. This integration test focuses on the QUIC connect path +
|
||||
// shared dispatch loop working end-to-end (the call above proves the
|
||||
// CallClient opened a real connection, the shared loop dispatched, and the
|
||||
// CallConnection::call() round-tripped).
|
||||
}
|
||||
Reference in New Issue
Block a user