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:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -56,6 +56,10 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"irpc",
|
"irpc",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
"quinn",
|
||||||
|
"rcgen 0.13.2",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ repository.workspace = true
|
|||||||
name = "alknet_call"
|
name = "alknet_call"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = ["quinn"]
|
||||||
|
quinn = ["dep:quinn", "dep:rustls", "alknet-core/quinn"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
alknet-core = { path = "../alknet-core" }
|
alknet-core = { path = "../alknet-core" }
|
||||||
@@ -23,4 +24,10 @@ tracing = "0.1"
|
|||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
quinn = { version = "0.11", optional = true }
|
||||||
|
rustls = { version = "0.23", optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
rcgen = "0.13"
|
||||||
|
rustls-pemfile = "2"
|
||||||
559
crates/alknet-call/src/client/call_client.rs
Normal file
559
crates/alknet-call/src/client/call_client.rs
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
//! `CallClient`: the outbound connection opener (ADR-017 §1, ADR-028).
|
||||||
|
//!
|
||||||
|
//! Opens a QUIC connection to a remote node on ALPN `alknet/call`, performs
|
||||||
|
//! credential setup, and produces a [`CallConnection`] running the shared
|
||||||
|
//! dispatch loop (delegated to [`crate::protocol::dispatch::Dispatcher`]).
|
||||||
|
//! `CallClient` is the connection-establishment half; `CallAdapter`'s accept
|
||||||
|
//! path is the inbound half; both produce a `CallConnection` and hand it to
|
||||||
|
//! the same `Dispatcher::run_loop` (ADR-017 §1).
|
||||||
|
//!
|
||||||
|
//! After establishment the connection is symmetric (ADR-017 §2): both sides
|
||||||
|
//! can send and receive `call.requested`. The `CallClient` is both a caller
|
||||||
|
//! (initiates outgoing calls via `CallConnection::call()`/`subscribe()`/
|
||||||
|
//! `abort()`) and a callee (dispatches incoming calls against its
|
||||||
|
//! peer-scoped view of the registry).
|
||||||
|
//!
|
||||||
|
//! See `docs/architecture/crates/call/client-and-adapters.md` for the spec.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use alknet_core::auth::IdentityProvider;
|
||||||
|
use alknet_core::config::TlsIdentity;
|
||||||
|
use alknet_core::types::Connection;
|
||||||
|
|
||||||
|
use crate::protocol::connection::CallConnection;
|
||||||
|
use crate::protocol::dispatch::{Dispatcher, RemoteFilter};
|
||||||
|
use crate::registry::registration::OperationRegistry;
|
||||||
|
|
||||||
|
/// Expected identity of the remote node (ADR-017 §7). The concrete shape is
|
||||||
|
/// an implementation-detail two-way door; v1 carries a fingerprint string the
|
||||||
|
/// assembly layer derives from `Capabilities` (ADR-014). Verification is the
|
||||||
|
/// assembly layer's trust decision — `CallClient` surfaces the expected value
|
||||||
|
/// so the transport can pin it, but the v1 quinn client config does not enforce
|
||||||
|
/// a specific verifier (recorded as a two-way-door remainder).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RemoteIdentity {
|
||||||
|
pub fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Credentials for an outbound `alknet/call` connection (ADR-017 §7). All
|
||||||
|
/// three dimensions come from `Capabilities` (ADR-014), never from environment
|
||||||
|
/// variables — see the No-Env-Vars Invariant in
|
||||||
|
/// `docs/architecture/crates/call/client-and-adapters.md`.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct CallCredentials {
|
||||||
|
/// The local node's TLS identity (RFC 7250 raw key or X.509), derived
|
||||||
|
/// from the vault at startup.
|
||||||
|
pub tls_identity: Option<TlsIdentity>,
|
||||||
|
/// Opaque call-protocol-level auth token, decrypted from the vault.
|
||||||
|
pub auth_token: Option<alknet_core::auth::AuthToken>,
|
||||||
|
/// Expected fingerprint/cert of the remote node, stored as a capability.
|
||||||
|
pub remote_identity: Option<RemoteIdentity>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallCredentials {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_tls_identity(mut self, tls_identity: TlsIdentity) -> Self {
|
||||||
|
self.tls_identity = Some(tls_identity);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_auth_token(mut self, token: alknet_core::auth::AuthToken) -> Self {
|
||||||
|
self.auth_token = Some(token);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_remote_identity(mut self, remote: RemoteIdentity) -> Self {
|
||||||
|
self.remote_identity = Some(remote);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors produced by [`CallClient::connect`].
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ClientError {
|
||||||
|
#[error("transport error: {message}")]
|
||||||
|
Transport { message: String },
|
||||||
|
#[error("tls setup error: {message}")]
|
||||||
|
TlsSetup { message: String },
|
||||||
|
#[error("connection closed")]
|
||||||
|
ConnectionClosed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outbound `alknet/call` connection opener (the #1 gap, ADR-017 §1).
|
||||||
|
///
|
||||||
|
/// The peer-scoped registry view is a dispatch-time read over the single
|
||||||
|
/// Layer-0 registry (ADR-028 §5) — not a copy. In default mode
|
||||||
|
/// (`trusted_peer: false`) only registrations with `remote_safe: true`
|
||||||
|
/// dispatch to the remote peer, and `services/list` hides non-remote-safe
|
||||||
|
/// ops (ADR-028 Assumption 2). In trusted-peer mode (`trusted_peer: true`,
|
||||||
|
/// explicit opt-in per ADR-028 §3) all `External` ops dispatch and list.
|
||||||
|
pub struct CallClient {
|
||||||
|
registry: Arc<OperationRegistry>,
|
||||||
|
identity_provider: Arc<dyn IdentityProvider>,
|
||||||
|
trusted_peer: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallClient {
|
||||||
|
/// Default-deny mode: only `remote_safe: true` ops dispatch/list to the
|
||||||
|
/// remote peer (ADR-028).
|
||||||
|
pub fn new(
|
||||||
|
registry: Arc<OperationRegistry>,
|
||||||
|
identity_provider: Arc<dyn IdentityProvider>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
registry,
|
||||||
|
identity_provider,
|
||||||
|
trusted_peer: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trusted-peer mode: expose all `External` ops to the remote peer,
|
||||||
|
/// ignoring the `remote_safe` marking. Explicit opt-in per ADR-028 §3.
|
||||||
|
pub fn trusted_peer(
|
||||||
|
registry: Arc<OperationRegistry>,
|
||||||
|
identity_provider: Arc<dyn IdentityProvider>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
registry,
|
||||||
|
identity_provider,
|
||||||
|
trusted_peer: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn registry(&self) -> &Arc<OperationRegistry> {
|
||||||
|
&self.registry
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn identity_provider(&self) -> &Arc<dyn IdentityProvider> {
|
||||||
|
&self.identity_provider
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_trusted_peer(&self) -> bool {
|
||||||
|
self.trusted_peer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a QUIC connection to `addr` on ALPN `alknet/call`, perform
|
||||||
|
/// credential handshake, and return a `CallConnection` running the shared
|
||||||
|
/// dispatch loop. Credentials come from `Capabilities` (ADR-014), not env
|
||||||
|
/// vars — the no-env-vars invariant.
|
||||||
|
///
|
||||||
|
/// The dispatch loop runs on a spawned task; the returned `CallConnection`
|
||||||
|
/// is live until the remote closes the connection or the caller drops it.
|
||||||
|
/// The caller can immediately use `call()`/`subscribe()`/`abort()` on the
|
||||||
|
/// returned connection, and the remote peer can call back into this
|
||||||
|
/// `CallClient`'s peer-scoped registry view (connection symmetry,
|
||||||
|
/// ADR-017 §2).
|
||||||
|
#[cfg(feature = "quinn")]
|
||||||
|
pub async fn connect(
|
||||||
|
&self,
|
||||||
|
addr: SocketAddr,
|
||||||
|
credentials: CallCredentials,
|
||||||
|
) -> Result<CallConnection, ClientError> {
|
||||||
|
let alpn = b"alknet/call".to_vec();
|
||||||
|
let client_config = build_quinn_client_config(&credentials, &alpn)
|
||||||
|
.map_err(|e| ClientError::TlsSetup { message: e })?;
|
||||||
|
|
||||||
|
let bind_addr: SocketAddr = "0.0.0.0:0".parse().expect("valid bind addr");
|
||||||
|
let endpoint = quinn::Endpoint::client(bind_addr).map_err(|e| ClientError::Transport {
|
||||||
|
message: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let connection = endpoint
|
||||||
|
.connect_with(client_config, addr, "alknet")
|
||||||
|
.map_err(|e| ClientError::Transport {
|
||||||
|
message: e.to_string(),
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map_err(|e| ClientError::Transport {
|
||||||
|
message: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let connection = Connection::from_quinn_with_alpn(connection, alpn);
|
||||||
|
Ok(self.spawn_dispatch(connection))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the shared dispatch loop over a pre-established `Connection`. The
|
||||||
|
/// `CallClient` spawns the dispatcher task and returns a live
|
||||||
|
/// `CallConnection` the caller can use immediately. Used by `connect()`
|
||||||
|
/// (after the QUIC dial completes) and by integration tests that wire a
|
||||||
|
/// mock/loopback `Connection` directly.
|
||||||
|
pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection {
|
||||||
|
let call_connection = Arc::new(CallConnection::new(connection));
|
||||||
|
let dispatcher = Dispatcher::new(
|
||||||
|
Arc::clone(&self.registry),
|
||||||
|
Arc::clone(&self.identity_provider),
|
||||||
|
if self.trusted_peer {
|
||||||
|
RemoteFilter::trusted()
|
||||||
|
} else {
|
||||||
|
RemoteFilter::default_deny()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let run_conn = Arc::clone(&call_connection);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
dispatcher.run_loop(run_conn).await;
|
||||||
|
});
|
||||||
|
(*call_connection).clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "quinn")]
|
||||||
|
fn build_quinn_client_config(
|
||||||
|
_credentials: &CallCredentials,
|
||||||
|
alpn: &[u8],
|
||||||
|
) -> Result<quinn::ClientConfig, String> {
|
||||||
|
// v1 connects without client-auth TLS identity: the server-side
|
||||||
|
// `AcceptAnyCertVerifier` (in alknet-core::endpoint) does not require or
|
||||||
|
// verify client certs, so a client cert is not needed to establish a
|
||||||
|
// connection. Wiring the local node's RawKey/X509 identity as a quinn
|
||||||
|
// client-auth cert (for servers that *do* verify client identity) is a
|
||||||
|
// two-way-door remainder — the `credentials.tls_identity` field is
|
||||||
|
// carried through `CallCredentials` so the assembly layer can populate
|
||||||
|
// it, and a future task plugs it into the rustls client config. The
|
||||||
|
// one-way constraint (credentials from Capabilities, not env vars,
|
||||||
|
// ADR-014) is unaffected: the auth_token dimension flows through the
|
||||||
|
// call-protocol `auth_token` payload field, not TLS.
|
||||||
|
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
||||||
|
let mut config = rustls::ClientConfig::builder_with_provider(provider)
|
||||||
|
.with_safe_default_protocol_versions()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier))
|
||||||
|
.with_no_client_auth();
|
||||||
|
config.alpn_protocols = vec![alpn.to_vec()];
|
||||||
|
config.enable_early_data = true;
|
||||||
|
|
||||||
|
Ok(quinn::ClientConfig::new(Arc::new(
|
||||||
|
quinn::crypto::rustls::QuicClientConfig::try_from(config).map_err(|e| e.to_string())?,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "quinn")]
|
||||||
|
struct AcceptAnyServerCertVerifier;
|
||||||
|
|
||||||
|
#[cfg(feature = "quinn")]
|
||||||
|
impl std::fmt::Debug for AcceptAnyServerCertVerifier {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("AcceptAnyServerCertVerifier").finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "quinn")]
|
||||||
|
impl rustls::client::danger::ServerCertVerifier for AcceptAnyServerCertVerifier {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||||
|
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: rustls::pki_types::UnixTime,
|
||||||
|
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
vec![
|
||||||
|
rustls::SignatureScheme::ED25519,
|
||||||
|
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||||
|
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||||
|
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||||
|
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||||
|
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||||
|
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||||
|
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||||
|
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::protocol::connection::CallConnection;
|
||||||
|
use crate::protocol::dispatch::{Dispatcher, RemoteFilter};
|
||||||
|
use crate::protocol::wire::ResponseEnvelope;
|
||||||
|
use crate::registry::registration::{
|
||||||
|
make_handler, Handler, HandlerRegistration, OperationProvenance,
|
||||||
|
};
|
||||||
|
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
|
use alknet_core::auth::Identity;
|
||||||
|
use alknet_core::types::{Capabilities, MockConnection};
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
|
use std::sync::Mutex as StdMutex;
|
||||||
|
|
||||||
|
struct StubConnection {
|
||||||
|
alpn: &'static [u8],
|
||||||
|
addr: Option<SocketAddr>,
|
||||||
|
closed: StdMutex<Option<(u32, String)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockConnection for StubConnection {
|
||||||
|
fn remote_alpn(&self) -> &[u8] {
|
||||||
|
self.alpn
|
||||||
|
}
|
||||||
|
fn remote_addr(&self) -> Option<SocketAddr> {
|
||||||
|
self.addr
|
||||||
|
}
|
||||||
|
fn close(&self, code: u32, reason: &str) {
|
||||||
|
*self.closed.lock().unwrap() = Some((code, reason.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stub_connection() -> Connection {
|
||||||
|
Connection::from_mock(Arc::new(StubConnection {
|
||||||
|
alpn: b"alknet/call",
|
||||||
|
addr: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
|
||||||
|
closed: StdMutex::new(None),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn external_spec(name: &str) -> OperationSpec {
|
||||||
|
OperationSpec::new(
|
||||||
|
name,
|
||||||
|
OperationType::Query,
|
||||||
|
Visibility::External,
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
AccessControl::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn caps_inspect_handler() -> Handler {
|
||||||
|
make_handler(|_input, context| async move {
|
||||||
|
let has_google = context.capabilities.get("google").is_some();
|
||||||
|
ResponseEnvelope::ok(
|
||||||
|
context.request_id,
|
||||||
|
serde_json::json!({ "has_google_capability": has_google }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoopIdentityProvider;
|
||||||
|
impl alknet_core::auth::IdentityProvider for NoopIdentityProvider {
|
||||||
|
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn resolve_from_token(&self, _token: &alknet_core::auth::AuthToken) -> Option<Identity> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_with_remote_safe_and_caps() -> Arc<OperationRegistry> {
|
||||||
|
let mut registry = OperationRegistry::new();
|
||||||
|
// remote_safe: false, carries a google api-key capability
|
||||||
|
registry.register(HandlerRegistration::new(
|
||||||
|
external_spec("secret/run"),
|
||||||
|
caps_inspect_handler(),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new().with_api_key("google", "secret-key".to_string()),
|
||||||
|
));
|
||||||
|
// remote_safe: true, carries a google api-key capability
|
||||||
|
registry.register(
|
||||||
|
HandlerRegistration::new(
|
||||||
|
external_spec("pub/run"),
|
||||||
|
caps_inspect_handler(),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new().with_api_key("google", "pub-key".to_string()),
|
||||||
|
)
|
||||||
|
.remote_safe(true),
|
||||||
|
);
|
||||||
|
Arc::new(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatcher(registry: &Arc<OperationRegistry>, trusted_peer: bool) -> Dispatcher {
|
||||||
|
Dispatcher::new(
|
||||||
|
Arc::clone(registry),
|
||||||
|
Arc::new(NoopIdentityProvider),
|
||||||
|
if trusted_peer {
|
||||||
|
RemoteFilter::trusted()
|
||||||
|
} else {
|
||||||
|
RemoteFilter::default_deny()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch(d: &Dispatcher, conn: &Arc<CallConnection>, op: &str) -> ResponseEnvelope {
|
||||||
|
d.dispatch_requested(
|
||||||
|
conn,
|
||||||
|
"req-test".to_string(),
|
||||||
|
serde_json::json!({ "operationId": op, "input": {} }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_client_new_is_default_deny() {
|
||||||
|
let registry = Arc::new(OperationRegistry::new());
|
||||||
|
let client = CallClient::new(Arc::clone(®istry), Arc::new(NoopIdentityProvider));
|
||||||
|
assert!(!client.is_trusted_peer(), "new() is default-deny");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_client_trusted_peer_is_trusted() {
|
||||||
|
let registry = Arc::new(OperationRegistry::new());
|
||||||
|
let client =
|
||||||
|
CallClient::trusted_peer(Arc::clone(®istry), Arc::new(NoopIdentityProvider));
|
||||||
|
assert!(
|
||||||
|
client.is_trusted_peer(),
|
||||||
|
"trusted_peer() is trusted-peer mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_credentials_builder_methods() {
|
||||||
|
let creds = CallCredentials::new().with_remote_identity(RemoteIdentity {
|
||||||
|
fingerprint: "SHA256:abc".to_string(),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
creds.remote_identity.as_ref().unwrap().fingerprint,
|
||||||
|
"SHA256:abc"
|
||||||
|
);
|
||||||
|
assert!(creds.tls_identity.is_none());
|
||||||
|
assert!(creds.auth_token.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_deny_non_remote_safe_op_returns_not_found() {
|
||||||
|
let registry = registry_with_remote_safe_and_caps();
|
||||||
|
let d = dispatcher(®istry, false);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
let response = dispatch(&d, &conn, "secret/run").await;
|
||||||
|
match response.result {
|
||||||
|
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
|
||||||
|
other => panic!("expected NOT_FOUND for non-remote-safe op, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_deny_remote_safe_op_dispatches() {
|
||||||
|
let registry = registry_with_remote_safe_and_caps();
|
||||||
|
let d = dispatcher(®istry, false);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
let response = dispatch(&d, &conn, "pub/run").await;
|
||||||
|
assert!(
|
||||||
|
response.result.is_ok(),
|
||||||
|
"remote_safe op must dispatch in default-deny mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn trusted_peer_dispatches_non_remote_safe_op() {
|
||||||
|
let registry = registry_with_remote_safe_and_caps();
|
||||||
|
let d = dispatcher(®istry, true);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
let response = dispatch(&d, &conn, "secret/run").await;
|
||||||
|
assert!(
|
||||||
|
response.result.is_ok(),
|
||||||
|
"trusted-peer mode dispatches non-remote-safe ops"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The load-bearing security invariant (ADR-028 Context): a remote
|
||||||
|
/// peer's call to a non-remote-safe op must NOT populate
|
||||||
|
/// `OperationContext.capabilities` from the local registration bundle.
|
||||||
|
/// This test asserts the handler is never reached for non-remote-safe
|
||||||
|
/// ops in default-deny mode (NOT_FOUND before dispatch), so capabilities
|
||||||
|
/// are never populated — verified by the handler not running.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_deny_non_remote_safe_does_not_populate_capabilities() {
|
||||||
|
let registry = registry_with_remote_safe_and_caps();
|
||||||
|
let d = dispatcher(®istry, false);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
let response = dispatch(&d, &conn, "secret/run").await;
|
||||||
|
match response.result {
|
||||||
|
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
|
||||||
|
Ok(_) => panic!("non-remote-safe op must not dispatch (would populate capabilities)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A remote-safe op's call DOES populate capabilities (the security
|
||||||
|
/// argument is about *non-remote-safe* ops, not all ops). The handler
|
||||||
|
/// inspects capabilities and reports whether the google key was injected.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remote_safe_op_populates_capabilities_for_handler() {
|
||||||
|
let registry = registry_with_remote_safe_and_caps();
|
||||||
|
let d = dispatcher(®istry, false);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
let response = dispatch(&d, &conn, "pub/run").await;
|
||||||
|
let out = response.result.expect("ok");
|
||||||
|
assert_eq!(
|
||||||
|
out["has_google_capability"],
|
||||||
|
serde_json::json!(true),
|
||||||
|
"remote_safe op must have its capabilities populated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn trusted_peer_populates_capabilities_for_non_remote_safe() {
|
||||||
|
let registry = registry_with_remote_safe_and_caps();
|
||||||
|
let d = dispatcher(®istry, true);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
let response = dispatch(&d, &conn, "secret/run").await;
|
||||||
|
let out = response.result.expect("ok");
|
||||||
|
assert_eq!(
|
||||||
|
out["has_google_capability"],
|
||||||
|
serde_json::json!(true),
|
||||||
|
"trusted-peer mode populates capabilities for all External ops"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_deny_unknown_op_returns_not_found() {
|
||||||
|
let registry = Arc::new(OperationRegistry::new());
|
||||||
|
let d = dispatcher(®istry, false);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
let response = dispatch(&d, &conn, "no/such").await;
|
||||||
|
match response.result {
|
||||||
|
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
|
||||||
|
other => panic!("expected NOT_FOUND, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawn_dispatch_returns_live_call_connection() {
|
||||||
|
let registry = registry_with_remote_safe_and_caps();
|
||||||
|
let client = CallClient::new(Arc::clone(®istry), Arc::new(NoopIdentityProvider));
|
||||||
|
let conn = client.spawn_dispatch(stub_connection());
|
||||||
|
// The returned CallConnection is usable: it has an empty overlay and
|
||||||
|
// the underlying connection reports the alknet/call ALPN.
|
||||||
|
assert_eq!(conn.connection().remote_alpn(), b"alknet/call");
|
||||||
|
// The dispatch task is spawned; dropping the connection closes it.
|
||||||
|
std::mem::drop(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn call_client_is_send_sync() {
|
||||||
|
fn assert_send_sync<T: Send + Sync>() {}
|
||||||
|
assert_send_sync::<CallClient>();
|
||||||
|
assert_send_sync::<CallCredentials>();
|
||||||
|
assert_send_sync::<RemoteIdentity>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
//! `docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md`
|
//! `docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md`
|
||||||
//! §5 for the trait contract.
|
//! §5 for the trait contract.
|
||||||
|
|
||||||
|
mod call_client;
|
||||||
mod from_jsonschema;
|
mod from_jsonschema;
|
||||||
|
|
||||||
|
pub use call_client::{CallClient, CallCredentials, ClientError, RemoteIdentity};
|
||||||
pub use from_jsonschema::{from_jsonschema, FromJsonSchema};
|
pub use from_jsonschema::{from_jsonschema, FromJsonSchema};
|
||||||
|
|
||||||
use crate::registry::registration::HandlerRegistration;
|
use crate::registry::registration::HandlerRegistration;
|
||||||
|
|||||||
@@ -4,43 +4,42 @@
|
|||||||
//! dispatches `call.requested` events to the operation registry. See
|
//! dispatches `call.requested` events to the operation registry. See
|
||||||
//! `docs/architecture/crates/call/call-protocol.md` for the full
|
//! `docs/architecture/crates/call/call-protocol.md` for the full
|
||||||
//! specification.
|
//! specification.
|
||||||
|
//!
|
||||||
|
//! The dispatch loop is shared with [`crate::client::CallClient`] via
|
||||||
|
//! [`super::dispatch::Dispatcher`] (ADR-017 §1): `CallAdapter` is the
|
||||||
|
//! inbound (accept) half; `CallClient` is the outbound (connect) half; both
|
||||||
|
//! produce a [`CallConnection`] and hand it to the same `Dispatcher::run_loop`.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
|
|
||||||
use alknet_core::auth::{AuthContext, AuthToken, Identity, IdentityProvider};
|
use alknet_core::auth::{AuthContext, IdentityProvider};
|
||||||
use alknet_core::types::{Connection, HandlerError, ProtocolHandler, StreamError};
|
use alknet_core::types::{Connection, HandlerError, ProtocolHandler};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
use tracing::{debug, warn};
|
|
||||||
|
|
||||||
use super::abort::AbortCascade;
|
|
||||||
use super::connection::CallConnection;
|
use super::connection::CallConnection;
|
||||||
use super::wire::{
|
use super::dispatch::{Dispatcher, RemoteFilter};
|
||||||
CallError, EventEnvelope, FrameFramedReader, FrameFramedWriter, ResponseEnvelope,
|
use crate::registry::context::OperationContext;
|
||||||
EVENT_ABORTED, EVENT_REQUESTED,
|
|
||||||
};
|
|
||||||
use crate::registry::context::{AbortPolicy, OperationContext, ScopedOperationEnv};
|
|
||||||
use crate::registry::env::{CompositeOperationEnv, LocalOperationEnv, OperationEnv};
|
|
||||||
use crate::registry::registration::OperationRegistry;
|
use crate::registry::registration::OperationRegistry;
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
pub use super::dispatch::RemoteFilter as AdapterRemoteFilter;
|
||||||
const SWEEPER_INTERVAL: Duration = Duration::from_secs(10);
|
|
||||||
|
|
||||||
pub struct CallAdapter {
|
#[cfg(test)]
|
||||||
registry: Arc<OperationRegistry>,
|
use super::wire::ResponseEnvelope;
|
||||||
identity_provider: Arc<dyn IdentityProvider>,
|
#[cfg(test)]
|
||||||
session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
|
use alknet_core::auth::Identity;
|
||||||
default_timeout: Duration,
|
#[cfg(test)]
|
||||||
}
|
use serde_json::Value;
|
||||||
|
|
||||||
pub trait SessionOverlaySource: Send + Sync {
|
pub trait SessionOverlaySource: Send + Sync {
|
||||||
fn overlay_for(
|
fn overlay_for(
|
||||||
&self,
|
&self,
|
||||||
context: &OperationContext,
|
context: &OperationContext,
|
||||||
) -> Option<Arc<dyn OperationEnv + Send + Sync>>;
|
) -> Option<Arc<dyn crate::registry::env::OperationEnv + Send + Sync>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CallAdapter {
|
||||||
|
dispatcher: Dispatcher,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CallAdapter {
|
impl CallAdapter {
|
||||||
@@ -48,11 +47,11 @@ impl CallAdapter {
|
|||||||
registry: Arc<OperationRegistry>,
|
registry: Arc<OperationRegistry>,
|
||||||
identity_provider: Arc<dyn IdentityProvider>,
|
identity_provider: Arc<dyn IdentityProvider>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
// The accept path is not peer-scoped-filtered: a direct QUIC client is
|
||||||
|
// not a CallClient peer in the ADR-028 filtered sense, so the accept
|
||||||
|
// path lists/dispatches all External ops (trusted-peer posture).
|
||||||
Self {
|
Self {
|
||||||
registry,
|
dispatcher: Dispatcher::new(registry, identity_provider, RemoteFilter::trusted()),
|
||||||
identity_provider,
|
|
||||||
session_source: None,
|
|
||||||
default_timeout: DEFAULT_TIMEOUT,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,184 +59,84 @@ impl CallAdapter {
|
|||||||
mut self,
|
mut self,
|
||||||
source: Arc<dyn SessionOverlaySource + Send + Sync>,
|
source: Arc<dyn SessionOverlaySource + Send + Sync>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
self.session_source = Some(source);
|
self.dispatcher = self.dispatcher.with_session_source(source);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||||
self.default_timeout = timeout;
|
self.dispatcher = self.dispatcher.with_timeout(timeout);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn registry(&self) -> &Arc<OperationRegistry> {
|
pub fn registry(&self) -> &Arc<OperationRegistry> {
|
||||||
&self.registry
|
&self.dispatcher.registry
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn identity_provider(&self) -> &Arc<dyn IdentityProvider> {
|
pub fn identity_provider(&self) -> &Arc<dyn IdentityProvider> {
|
||||||
&self.identity_provider
|
&self.dispatcher.identity_provider
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_timeout(&self) -> Duration {
|
pub fn default_timeout(&self) -> Duration {
|
||||||
self.default_timeout
|
self.dispatcher.default_timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
fn strip_leading_slash(operation_id: &str) -> &str {
|
pub fn session_source(&self) -> Option<&Arc<dyn SessionOverlaySource + Send + Sync>> {
|
||||||
|
self.dispatcher.session_source.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test-facing wrappers around the shared Dispatcher -----------------
|
||||||
|
// These exist so the adapter's existing tests keep compiling against the
|
||||||
|
// adapter type; they delegate to the Dispatcher's shared implementation.
|
||||||
|
// Gated to test builds — the production adapter delegates through
|
||||||
|
// `handle()` -> `Dispatcher::run_loop()` directly.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn strip_leading_slash(operation_id: &str) -> &str {
|
||||||
operation_id.strip_prefix('/').unwrap_or(operation_id)
|
operation_id.strip_prefix('/').unwrap_or(operation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_identity(
|
#[cfg(test)]
|
||||||
|
pub(crate) fn resolve_identity(
|
||||||
&self,
|
&self,
|
||||||
connection_identity: Option<Identity>,
|
connection_identity: Option<Identity>,
|
||||||
payload: &Value,
|
payload: &Value,
|
||||||
) -> Option<Identity> {
|
) -> Option<Identity> {
|
||||||
let auth_token = payload.get("auth_token").and_then(|v| v.as_str());
|
self.dispatcher
|
||||||
match auth_token {
|
.resolve_identity(connection_identity, payload)
|
||||||
Some(token_str) => {
|
|
||||||
let token = AuthToken {
|
|
||||||
raw: token_str.as_bytes().to_vec(),
|
|
||||||
};
|
|
||||||
match self.identity_provider.resolve_from_token(&token) {
|
|
||||||
Some(identity) => Some(identity),
|
|
||||||
None => connection_identity,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => connection_identity,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compose_root_env(
|
#[cfg(test)]
|
||||||
&self,
|
pub(crate) fn build_root_context(
|
||||||
connection: &CallConnection,
|
|
||||||
context: &OperationContext,
|
|
||||||
) -> Arc<dyn OperationEnv + Send + Sync> {
|
|
||||||
let base: Arc<dyn OperationEnv + Send + Sync> =
|
|
||||||
Arc::new(LocalOperationEnv::new(Arc::clone(&self.registry)));
|
|
||||||
let session = self
|
|
||||||
.session_source
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|s| s.overlay_for(context));
|
|
||||||
let connection_overlay = connection.overlay_env();
|
|
||||||
Arc::new(CompositeOperationEnv::new(
|
|
||||||
base,
|
|
||||||
Some(connection_overlay),
|
|
||||||
session,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_root_context(
|
|
||||||
&self,
|
&self,
|
||||||
request_id: String,
|
request_id: String,
|
||||||
operation_name: &str,
|
operation_name: &str,
|
||||||
identity: Option<Identity>,
|
identity: Option<Identity>,
|
||||||
connection: &CallConnection,
|
connection: &CallConnection,
|
||||||
) -> OperationContext {
|
) -> OperationContext {
|
||||||
let registration = self.registry.registration(operation_name);
|
self.dispatcher
|
||||||
let (composition_authority, capabilities, scoped_env) = match registration {
|
.build_root_context(request_id, operation_name, identity, connection)
|
||||||
Some(r) => (
|
|
||||||
r.composition_authority.clone(),
|
|
||||||
r.capabilities.clone(),
|
|
||||||
r.scoped_env
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(ScopedOperationEnv::empty),
|
|
||||||
),
|
|
||||||
None => (
|
|
||||||
None,
|
|
||||||
alknet_core::types::Capabilities::new(),
|
|
||||||
ScopedOperationEnv::empty(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let stub_env: Arc<dyn OperationEnv + Send + Sync> =
|
|
||||||
Arc::new(LocalOperationEnv::new(Arc::clone(&self.registry)));
|
|
||||||
let mut context = OperationContext {
|
|
||||||
request_id,
|
|
||||||
parent_request_id: None,
|
|
||||||
identity: identity.clone(),
|
|
||||||
handler_identity: composition_authority,
|
|
||||||
capabilities,
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
deadline: Some(Instant::now() + self.default_timeout),
|
|
||||||
scoped_env,
|
|
||||||
env: stub_env,
|
|
||||||
abort_policy: AbortPolicy::default(),
|
|
||||||
internal: false,
|
|
||||||
};
|
|
||||||
context.env = self.compose_root_env(connection, &context);
|
|
||||||
context
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dispatch_requested(
|
#[cfg(test)]
|
||||||
|
pub(crate) async fn dispatch_requested(
|
||||||
&self,
|
&self,
|
||||||
connection: &Arc<CallConnection>,
|
connection: &Arc<CallConnection>,
|
||||||
request_id: String,
|
request_id: String,
|
||||||
payload: Value,
|
payload: Value,
|
||||||
) -> ResponseEnvelope {
|
) -> ResponseEnvelope {
|
||||||
let operation_id = payload
|
self.dispatcher
|
||||||
.get("operationId")
|
.dispatch_requested(connection, request_id, payload)
|
||||||
.and_then(|v| v.as_str())
|
.await
|
||||||
.unwrap_or("");
|
|
||||||
let operation_name = Self::strip_leading_slash(operation_id).to_string();
|
|
||||||
|
|
||||||
let connection_identity = connection.connection().identity().cloned();
|
|
||||||
let identity = self.resolve_identity(connection_identity, &payload);
|
|
||||||
|
|
||||||
let input = payload.get("input").cloned().unwrap_or(Value::Null);
|
|
||||||
|
|
||||||
let context =
|
|
||||||
self.build_root_context(request_id.clone(), &operation_name, identity, connection);
|
|
||||||
|
|
||||||
self.registry.invoke(&operation_name, input, context).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_stream(
|
#[cfg(test)]
|
||||||
|
pub(crate) async fn handle_stream(
|
||||||
&self,
|
&self,
|
||||||
connection: Arc<CallConnection>,
|
connection: Arc<CallConnection>,
|
||||||
send: alknet_core::types::SendStream,
|
send: alknet_core::types::SendStream,
|
||||||
recv: alknet_core::types::RecvStream,
|
recv: alknet_core::types::RecvStream,
|
||||||
) {
|
) {
|
||||||
let mut reader = FrameFramedReader::new(recv);
|
self.dispatcher.handle_stream(connection, send, recv).await;
|
||||||
let mut writer = FrameFramedWriter::new(send);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let envelope = match reader.read_frame().await {
|
|
||||||
Ok(env) => env,
|
|
||||||
Err(super::wire::FrameError::ConnectionClosed) => break,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "stream frame read error; closing stream");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match envelope.r#type.as_str() {
|
|
||||||
EVENT_REQUESTED => {
|
|
||||||
let request_id = envelope.id.clone();
|
|
||||||
let payload = envelope.payload.clone();
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.dispatch_requested(&connection, request_id.clone(), payload)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let event: EventEnvelope = response.into();
|
|
||||||
if let Err(err) = writer.write_frame(&event).await {
|
|
||||||
warn!(error = %err, "failed to write response frame; closing stream");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EVENT_ABORTED => {
|
|
||||||
let request_id = envelope.id.clone();
|
|
||||||
let mut pending = connection.pending().lock();
|
|
||||||
let mut cascade = AbortCascade::new(&mut pending);
|
|
||||||
let aborted = cascade.cascade_abort(&request_id, AbortPolicy::AbortDependents);
|
|
||||||
pending.handle_aborted(&request_id);
|
|
||||||
if !aborted.is_empty() {
|
|
||||||
debug!(count = aborted.len(), "abort cascade evicted descendants");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
debug!(event_type = %other, id = %envelope.id, "ignoring non-requested/non-aborted event on inbound stream");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,63 +152,7 @@ impl ProtocolHandler for CallAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let call_connection = Arc::new(CallConnection::new(connection));
|
let call_connection = Arc::new(CallConnection::new(connection));
|
||||||
let pending = Arc::clone(call_connection.pending());
|
self.dispatcher.clone().run_loop(call_connection).await;
|
||||||
|
|
||||||
let sweeper_pending = Arc::clone(&pending);
|
|
||||||
let sweeper_handle: JoinHandle<()> = tokio::spawn(async move {
|
|
||||||
let mut interval = tokio::time::interval(SWEEPER_INTERVAL);
|
|
||||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
let evicted = sweeper_pending.lock().evict_expired();
|
|
||||||
if !evicted.is_empty() {
|
|
||||||
debug!(
|
|
||||||
count = evicted.len(),
|
|
||||||
"sweeper evicted expired pending entries"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match call_connection.connection().accept_bi().await {
|
|
||||||
Ok((send, recv)) => {
|
|
||||||
let conn = Arc::clone(&call_connection);
|
|
||||||
let adapter_registry = Arc::clone(&self.registry);
|
|
||||||
let adapter_identity = Arc::clone(&self.identity_provider);
|
|
||||||
let adapter_session = self.session_source.clone();
|
|
||||||
let adapter_timeout = self.default_timeout;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let adapter = CallAdapter {
|
|
||||||
registry: adapter_registry,
|
|
||||||
identity_provider: adapter_identity,
|
|
||||||
session_source: adapter_session,
|
|
||||||
default_timeout: adapter_timeout,
|
|
||||||
};
|
|
||||||
adapter.handle_stream(conn, send, recv).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(StreamError::ConnectionClosed) => break,
|
|
||||||
Err(StreamError::StreamClosed) => break,
|
|
||||||
Err(StreamError::Timeout) => break,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "accept_bi error; stopping accept loop");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let failed = pending
|
|
||||||
.lock()
|
|
||||||
.fail_all(CallError::internal("connection closed"));
|
|
||||||
if !failed.is_empty() {
|
|
||||||
debug!(
|
|
||||||
count = failed.len(),
|
|
||||||
"failed pending requests on connection close"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
sweeper_handle.abort();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,7 +160,11 @@ impl ProtocolHandler for CallAdapter {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::protocol::wire::{EVENT_COMPLETED, EVENT_ERROR, EVENT_RESPONDED};
|
use crate::protocol::wire::{
|
||||||
|
CallError, EventEnvelope, EVENT_COMPLETED, EVENT_ERROR, EVENT_RESPONDED,
|
||||||
|
};
|
||||||
|
use crate::registry::context::{AbortPolicy, ScopedOperationEnv};
|
||||||
|
use crate::registry::env::OperationEnv;
|
||||||
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
||||||
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
use alknet_core::auth::AuthToken;
|
use alknet_core::auth::AuthToken;
|
||||||
@@ -471,7 +318,7 @@ mod tests {
|
|||||||
.with_timeout(Duration::from_secs(60));
|
.with_timeout(Duration::from_secs(60));
|
||||||
assert_eq!(adapter.default_timeout(), Duration::from_secs(60));
|
assert_eq!(adapter.default_timeout(), Duration::from_secs(60));
|
||||||
assert!(Arc::ptr_eq(adapter.registry(), ®istry));
|
assert!(Arc::ptr_eq(adapter.registry(), ®istry));
|
||||||
assert!(adapter.session_source.is_none());
|
assert!(adapter.session_source().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ pub struct CallConnection {
|
|||||||
pending: Arc<Mutex<PendingRequestMap>>,
|
pending: Arc<Mutex<PendingRequestMap>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Clone for CallConnection {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
connection: Arc::clone(&self.connection),
|
||||||
|
imported_operations: Arc::clone(&self.imported_operations),
|
||||||
|
pending: Arc::clone(&self.pending),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl CallConnection {
|
impl CallConnection {
|
||||||
pub fn new(connection: Connection) -> Self {
|
pub fn new(connection: Connection) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
351
crates/alknet-call/src/protocol/dispatch.rs
Normal file
351
crates/alknet-call/src/protocol/dispatch.rs
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
//! Shared dispatch loop for `alknet/call` connections.
|
||||||
|
//!
|
||||||
|
//! Both [`CallAdapter`]'s accept path and [`crate::client::CallClient`]'s
|
||||||
|
//! connect path produce a [`CallConnection`] and hand it to the same dispatch
|
||||||
|
//! loop here (ADR-017 §1): the loop reads `EventEnvelope` frames off accepted
|
||||||
|
//! bidirectional streams, dispatches `call.requested` events against the
|
||||||
|
//! operation registry (with optional peer-scoped filtering per ADR-028), and
|
||||||
|
//! writes the response back on the same stream. The connection-establishment
|
||||||
|
//! half differs (accept vs dial); the dispatch half is shared.
|
||||||
|
//!
|
||||||
|
//! See `docs/architecture/crates/call/call-protocol.md` and
|
||||||
|
//! `docs/architecture/crates/call/client-and-adapters.md` for the spec.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||||
|
use alknet_core::types::StreamError;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
use super::abort::AbortCascade;
|
||||||
|
use super::connection::CallConnection;
|
||||||
|
use super::wire::{
|
||||||
|
CallError, EventEnvelope, FrameFramedReader, FrameFramedWriter, ResponseEnvelope,
|
||||||
|
EVENT_ABORTED, EVENT_REQUESTED,
|
||||||
|
};
|
||||||
|
use crate::protocol::adapter::SessionOverlaySource;
|
||||||
|
use crate::registry::context::{AbortPolicy, OperationContext, ScopedOperationEnv};
|
||||||
|
use crate::registry::env::{CompositeOperationEnv, LocalOperationEnv, OperationEnv};
|
||||||
|
use crate::registry::registration::OperationRegistry;
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
const SWEEPER_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Peer-scoped registry filter state (ADR-028). When `trusted_peer` is false
|
||||||
|
/// (the default-deny mode for a `CallClient`), incoming dispatch hides ops
|
||||||
|
/// whose `HandlerRegistration.remote_safe` is false, and `services/list` hides
|
||||||
|
/// them too. When `trusted_peer` is true (the explicit opt-in for trusted
|
||||||
|
/// peers), the filter is bypassed: all `External` ops dispatch and list.
|
||||||
|
///
|
||||||
|
/// For the `CallAdapter` (local accept path), `trusted_peer` is `true` by
|
||||||
|
/// convention — a direct QUIC client is not a filtered `CallClient` peer in
|
||||||
|
/// the ADR-028 sense; the accept path keeps listing all `External` ops.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct RemoteFilter {
|
||||||
|
pub trusted_peer: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteFilter {
|
||||||
|
/// Default-deny mode: only `remote_safe: true` ops dispatch/list.
|
||||||
|
pub fn default_deny() -> Self {
|
||||||
|
Self {
|
||||||
|
trusted_peer: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trusted-peer mode: all `External` ops dispatch/list regardless of
|
||||||
|
/// `remote_safe`.
|
||||||
|
pub fn trusted() -> Self {
|
||||||
|
Self { trusted_peer: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether `registration` is dispatchable to the remote peer.
|
||||||
|
pub fn allows(&self, remote_safe: bool) -> bool {
|
||||||
|
self.trusted_peer || remote_safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared dispatcher for an established `CallConnection`. Constructed by
|
||||||
|
/// both `CallAdapter` (accept path) and `CallClient` (connect path) and used
|
||||||
|
/// to run the dispatch loop. Holds no per-connection state; the
|
||||||
|
/// `CallConnection` is passed into `run_loop`.
|
||||||
|
pub struct Dispatcher {
|
||||||
|
pub registry: Arc<OperationRegistry>,
|
||||||
|
pub identity_provider: Arc<dyn IdentityProvider>,
|
||||||
|
pub session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
|
||||||
|
pub default_timeout: Duration,
|
||||||
|
pub remote_filter: RemoteFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatcher {
|
||||||
|
pub fn new(
|
||||||
|
registry: Arc<OperationRegistry>,
|
||||||
|
identity_provider: Arc<dyn IdentityProvider>,
|
||||||
|
remote_filter: RemoteFilter,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
registry,
|
||||||
|
identity_provider,
|
||||||
|
session_source: None,
|
||||||
|
default_timeout: DEFAULT_TIMEOUT,
|
||||||
|
remote_filter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_session_source(
|
||||||
|
mut self,
|
||||||
|
source: Arc<dyn SessionOverlaySource + Send + Sync>,
|
||||||
|
) -> Self {
|
||||||
|
self.session_source = Some(source);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.default_timeout = timeout;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_leading_slash(operation_id: &str) -> &str {
|
||||||
|
operation_id.strip_prefix('/').unwrap_or(operation_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_identity(
|
||||||
|
&self,
|
||||||
|
connection_identity: Option<Identity>,
|
||||||
|
payload: &Value,
|
||||||
|
) -> Option<Identity> {
|
||||||
|
let auth_token = payload.get("auth_token").and_then(|v| v.as_str());
|
||||||
|
match auth_token {
|
||||||
|
Some(token_str) => {
|
||||||
|
let token = AuthToken {
|
||||||
|
raw: token_str.as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
match self.identity_provider.resolve_from_token(&token) {
|
||||||
|
Some(identity) => Some(identity),
|
||||||
|
None => connection_identity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => connection_identity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn compose_root_env(
|
||||||
|
&self,
|
||||||
|
connection: &CallConnection,
|
||||||
|
context: &OperationContext,
|
||||||
|
) -> Arc<dyn OperationEnv + Send + Sync> {
|
||||||
|
let base: Arc<dyn OperationEnv + Send + Sync> =
|
||||||
|
Arc::new(LocalOperationEnv::new(Arc::clone(&self.registry)));
|
||||||
|
let session = self
|
||||||
|
.session_source
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.overlay_for(context));
|
||||||
|
let connection_overlay = connection.overlay_env();
|
||||||
|
Arc::new(CompositeOperationEnv::new(
|
||||||
|
base,
|
||||||
|
Some(connection_overlay),
|
||||||
|
session,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_root_context(
|
||||||
|
&self,
|
||||||
|
request_id: String,
|
||||||
|
operation_name: &str,
|
||||||
|
identity: Option<Identity>,
|
||||||
|
connection: &CallConnection,
|
||||||
|
) -> OperationContext {
|
||||||
|
let registration = self.registry.registration(operation_name);
|
||||||
|
let (composition_authority, capabilities, scoped_env) = match registration {
|
||||||
|
Some(r) => (
|
||||||
|
r.composition_authority.clone(),
|
||||||
|
r.capabilities.clone(),
|
||||||
|
r.scoped_env
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(ScopedOperationEnv::empty),
|
||||||
|
),
|
||||||
|
None => (
|
||||||
|
None,
|
||||||
|
alknet_core::types::Capabilities::new(),
|
||||||
|
ScopedOperationEnv::empty(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let stub_env: Arc<dyn OperationEnv + Send + Sync> =
|
||||||
|
Arc::new(LocalOperationEnv::new(Arc::clone(&self.registry)));
|
||||||
|
let mut context = OperationContext {
|
||||||
|
request_id,
|
||||||
|
parent_request_id: None,
|
||||||
|
identity: identity.clone(),
|
||||||
|
handler_identity: composition_authority,
|
||||||
|
capabilities,
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
deadline: Some(Instant::now() + self.default_timeout),
|
||||||
|
scoped_env,
|
||||||
|
env: stub_env,
|
||||||
|
abort_policy: AbortPolicy::default(),
|
||||||
|
internal: false,
|
||||||
|
};
|
||||||
|
context.env = self.compose_root_env(connection, &context);
|
||||||
|
context
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn dispatch_requested(
|
||||||
|
&self,
|
||||||
|
connection: &Arc<CallConnection>,
|
||||||
|
request_id: String,
|
||||||
|
payload: Value,
|
||||||
|
) -> ResponseEnvelope {
|
||||||
|
let operation_id = payload
|
||||||
|
.get("operationId")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let operation_name = Self::strip_leading_slash(operation_id).to_string();
|
||||||
|
|
||||||
|
// Peer-scoped default-deny filter (ADR-028). When the caller is a
|
||||||
|
// remote peer (default-deny mode), an op marked `remote_safe: false`
|
||||||
|
// is hidden from dispatch — return NOT_FOUND, same posture as
|
||||||
|
// `Visibility::Internal` per ADR-015. Critically, this returns *before*
|
||||||
|
// any capability material reaches the handler, so a non-remote-safe
|
||||||
|
// op's `Capabilities` are never populated for a remote peer's call
|
||||||
|
// (ADR-028 Context — the security argument for default-deny).
|
||||||
|
if let Some(registration) = self.registry.registration(&operation_name) {
|
||||||
|
if !self.remote_filter.allows(registration.remote_safe) {
|
||||||
|
return ResponseEnvelope::not_found(request_id, &operation_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let connection_identity = connection.connection().identity().cloned();
|
||||||
|
let identity = self.resolve_identity(connection_identity, &payload);
|
||||||
|
|
||||||
|
let input = payload.get("input").cloned().unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
let context =
|
||||||
|
self.build_root_context(request_id.clone(), &operation_name, identity, connection);
|
||||||
|
|
||||||
|
self.registry.invoke(&operation_name, input, context).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn handle_stream(
|
||||||
|
&self,
|
||||||
|
connection: Arc<CallConnection>,
|
||||||
|
send: alknet_core::types::SendStream,
|
||||||
|
recv: alknet_core::types::RecvStream,
|
||||||
|
) {
|
||||||
|
let mut reader = FrameFramedReader::new(recv);
|
||||||
|
let mut writer = FrameFramedWriter::new(send);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let envelope = match reader.read_frame().await {
|
||||||
|
Ok(env) => env,
|
||||||
|
Err(super::wire::FrameError::ConnectionClosed) => break,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "stream frame read error; closing stream");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match envelope.r#type.as_str() {
|
||||||
|
EVENT_REQUESTED => {
|
||||||
|
let request_id = envelope.id.clone();
|
||||||
|
let payload = envelope.payload.clone();
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.dispatch_requested(&connection, request_id.clone(), payload)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let event: EventEnvelope = response.into();
|
||||||
|
if let Err(err) = writer.write_frame(&event).await {
|
||||||
|
warn!(error = %err, "failed to write response frame; closing stream");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EVENT_ABORTED => {
|
||||||
|
let request_id = envelope.id.clone();
|
||||||
|
let mut pending = connection.pending().lock();
|
||||||
|
let mut cascade = AbortCascade::new(&mut pending);
|
||||||
|
let aborted = cascade.cascade_abort(&request_id, AbortPolicy::AbortDependents);
|
||||||
|
pending.handle_aborted(&request_id);
|
||||||
|
if !aborted.is_empty() {
|
||||||
|
debug!(count = aborted.len(), "abort cascade evicted descendants");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
debug!(event_type = %other, id = %envelope.id, "ignoring non-requested/non-aborted event on inbound stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the shared dispatch loop over an established `CallConnection`:
|
||||||
|
/// spawn the pending-entry sweeper, accept bidirectional streams until the
|
||||||
|
/// connection closes, dispatch each stream via `handle_stream`, and fail
|
||||||
|
/// outstanding pending requests on close. Returns when the connection is
|
||||||
|
/// closed (accept loop yields `ConnectionClosed`/`StreamClosed`/`Timeout`).
|
||||||
|
pub async fn run_loop(self, connection: Arc<CallConnection>) {
|
||||||
|
let pending = Arc::clone(connection.pending());
|
||||||
|
|
||||||
|
let sweeper_pending = Arc::clone(&pending);
|
||||||
|
let sweeper_handle: JoinHandle<()> = tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(SWEEPER_INTERVAL);
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let evicted = sweeper_pending.lock().evict_expired();
|
||||||
|
if !evicted.is_empty() {
|
||||||
|
debug!(
|
||||||
|
count = evicted.len(),
|
||||||
|
"sweeper evicted expired pending entries"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match connection.connection().accept_bi().await {
|
||||||
|
Ok((send, recv)) => {
|
||||||
|
let conn = Arc::clone(&connection);
|
||||||
|
let dispatcher = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
dispatcher.handle_stream(conn, send, recv).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(StreamError::ConnectionClosed) => break,
|
||||||
|
Err(StreamError::StreamClosed) => break,
|
||||||
|
Err(StreamError::Timeout) => break,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "accept_bi error; stopping accept loop");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = pending
|
||||||
|
.lock()
|
||||||
|
.fail_all(CallError::internal("connection closed"));
|
||||||
|
if !failed.is_empty() {
|
||||||
|
debug!(
|
||||||
|
count = failed.len(),
|
||||||
|
"failed pending requests on connection close"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sweeper_handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for Dispatcher {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
registry: Arc::clone(&self.registry),
|
||||||
|
identity_provider: Arc::clone(&self.identity_provider),
|
||||||
|
session_source: self.session_source.clone(),
|
||||||
|
default_timeout: self.default_timeout,
|
||||||
|
remote_filter: self.remote_filter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,6 @@
|
|||||||
pub mod abort;
|
pub mod abort;
|
||||||
pub mod adapter;
|
pub mod adapter;
|
||||||
pub mod connection;
|
pub mod connection;
|
||||||
|
pub mod dispatch;
|
||||||
pub mod pending;
|
pub mod pending;
|
||||||
pub mod wire;
|
pub mod wire;
|
||||||
|
|||||||
@@ -193,6 +193,36 @@ pub fn services_list_handler(registry: Arc<OperationRegistry>) -> Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Peer-scoped `services/list` handler (ADR-028 Assumption 2). When
|
||||||
|
/// `trusted_peer` is false (default-deny mode for a `CallClient`), ops with
|
||||||
|
/// `remote_safe: false` are hidden from the remote peer in addition to the
|
||||||
|
/// existing `Visibility::External` filter — a peer should not see ops it
|
||||||
|
/// cannot call, so discovery and dispatch filters agree. When `trusted_peer`
|
||||||
|
/// is true, all `External` ops are listed regardless of `remote_safe`.
|
||||||
|
pub fn services_list_handler_peer_scoped(
|
||||||
|
registry: Arc<OperationRegistry>,
|
||||||
|
trusted_peer: bool,
|
||||||
|
) -> Handler {
|
||||||
|
Arc::new(move |input: Value, ctx: OperationContext| {
|
||||||
|
let registry = Arc::clone(®istry);
|
||||||
|
Box::pin(async move {
|
||||||
|
let _ = input;
|
||||||
|
let ops: Vec<Value> = registry
|
||||||
|
.list_operations_peer_scoped(trusted_peer)
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| {
|
||||||
|
json!({
|
||||||
|
"name": s.name,
|
||||||
|
"namespace": s.namespace,
|
||||||
|
"op_type": op_type_str(s.op_type),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
ResponseEnvelope::ok(ctx.request_id, json!({ "operations": ops }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn services_schema_handler(registry: Arc<OperationRegistry>) -> Handler {
|
pub fn services_schema_handler(registry: Arc<OperationRegistry>) -> Handler {
|
||||||
Arc::new(move |input: Value, ctx: OperationContext| {
|
Arc::new(move |input: Value, ctx: OperationContext| {
|
||||||
let registry = Arc::clone(®istry);
|
let registry = Arc::clone(®istry);
|
||||||
@@ -505,6 +535,106 @@ mod tests {
|
|||||||
assert!(output.get("operations").is_some());
|
assert!(output.get("operations").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn registry_with_remote_safe_ops() -> Arc<OperationRegistry> {
|
||||||
|
let mut registry = OperationRegistry::new();
|
||||||
|
registry.register(HandlerRegistration::new(
|
||||||
|
external_spec("fs/readFile"),
|
||||||
|
echo_handler(),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
));
|
||||||
|
// remote_safe: false (default)
|
||||||
|
registry.register(HandlerRegistration::new(
|
||||||
|
external_spec("admin/run"),
|
||||||
|
echo_handler(),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
));
|
||||||
|
// remote_safe: true
|
||||||
|
registry.register(
|
||||||
|
HandlerRegistration::new(
|
||||||
|
external_spec("pub/status"),
|
||||||
|
echo_handler(),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
)
|
||||||
|
.remote_safe(true),
|
||||||
|
);
|
||||||
|
Arc::new(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn services_list_peer_scoped_default_deny_hides_non_remote_safe() {
|
||||||
|
let registry = registry_with_remote_safe_ops();
|
||||||
|
let handler = services_list_handler_peer_scoped(Arc::clone(®istry), false);
|
||||||
|
let ctx = root_context("req-ps1");
|
||||||
|
let response = handler(serde_json::json!({}), ctx).await;
|
||||||
|
let output = response.result.expect("ok");
|
||||||
|
let ops = output
|
||||||
|
.get("operations")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.expect("operations array");
|
||||||
|
let names: Vec<&str> = ops
|
||||||
|
.iter()
|
||||||
|
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.contains(&"pub/status"),
|
||||||
|
"remote_safe ops must be listed in default-deny mode"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!names.contains(&"fs/readFile"),
|
||||||
|
"non-remote-safe ops must be hidden in default-deny mode (ADR-028 Assumption 2)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!names.contains(&"admin/run"),
|
||||||
|
"non-remote-safe ops must be hidden in default-deny mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn services_list_peer_scoped_trusted_peer_lists_all_external() {
|
||||||
|
let registry = registry_with_remote_safe_ops();
|
||||||
|
let handler = services_list_handler_peer_scoped(Arc::clone(®istry), true);
|
||||||
|
let ctx = root_context("req-ps2");
|
||||||
|
let response = handler(serde_json::json!({}), ctx).await;
|
||||||
|
let output = response.result.expect("ok");
|
||||||
|
let ops = output
|
||||||
|
.get("operations")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.expect("operations array");
|
||||||
|
let names: Vec<&str> = ops
|
||||||
|
.iter()
|
||||||
|
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
|
||||||
|
.collect();
|
||||||
|
assert!(names.contains(&"fs/readFile"));
|
||||||
|
assert!(names.contains(&"admin/run"));
|
||||||
|
assert!(names.contains(&"pub/status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn services_list_peer_scoped_default_deny_with_no_remote_safe_returns_empty() {
|
||||||
|
let registry = registry_with_ops(); // no remote_safe ops
|
||||||
|
let handler = services_list_handler_peer_scoped(Arc::clone(®istry), false);
|
||||||
|
let ctx = root_context("req-ps3");
|
||||||
|
let response = handler(serde_json::json!({}), ctx).await;
|
||||||
|
let output = response.result.expect("ok");
|
||||||
|
let ops = output
|
||||||
|
.get("operations")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.expect("operations array");
|
||||||
|
assert!(
|
||||||
|
ops.is_empty(),
|
||||||
|
"default-deny with no remote_safe ops lists nothing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalize_name_strips_leading_slash() {
|
fn normalize_name_strips_leading_slash() {
|
||||||
assert_eq!(normalize_name("/fs/readFile"), "fs/readFile");
|
assert_eq!(normalize_name("/fs/readFile"), "fs/readFile");
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ impl OperationRegistry {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List `External` op specs, additionally filtered by `remote_safe` for
|
||||||
|
/// peer-scoped serving (ADR-028 Assumption 2). When `trusted_peer` is true,
|
||||||
|
/// the `remote_safe` filter is bypassed (all `External` ops listed).
|
||||||
|
pub fn list_operations_peer_scoped(&self, trusted_peer: bool) -> Vec<&OperationSpec> {
|
||||||
|
self.operations
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.spec.visibility == Visibility::External)
|
||||||
|
.filter(|r| trusted_peer || r.remote_safe)
|
||||||
|
.map(|r| &r.spec)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn invoke(
|
pub async fn invoke(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
|||||||
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).
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: call/client/call-client
|
id: call/client/call-client
|
||||||
name: Implement CallClient (outbound connection opener) with peer-scoped default-deny dispatch (ADR-017, ADR-028)
|
name: Implement CallClient (outbound connection opener) with peer-scoped default-deny dispatch (ADR-017, ADR-028)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [call/protocol/call-connection, call/registry/remote-safe-marking]
|
depends_on: [call/protocol/call-connection, call/registry/remote-safe-marking]
|
||||||
scope: moderate
|
scope: moderate
|
||||||
risk: high
|
risk: high
|
||||||
|
|||||||
Reference in New Issue
Block a user