refactor(server::client): migrated to new connection design

This commit is contained in:
hdbg
2026-03-18 22:40:07 +01:00
committed by Stas
parent 04bea299cb
commit a663363626
14 changed files with 474 additions and 401 deletions

View File

@@ -1,142 +1,118 @@
use arbiter_proto::{
proto::client::{
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthOk as ProtoAuthOk,
ClientConnectError, ClientRequest, ClientResponse,
client_connect_error::Code as ProtoClientConnectErrorCode,
ClientRequest, ClientResponse, VaultState as ProtoVaultState,
client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
transport::{Bi, Error as TransportError, Sender},
transport::{Receiver, Sender, grpc::GrpcBi},
};
use async_trait::async_trait;
use futures::StreamExt as _;
use tokio::sync::mpsc;
use tonic::{Status, Streaming};
use kameo::{
actor::{ActorRef, Spawn as _},
error::SendError,
};
use tracing::{info, warn};
use crate::actors::client::{
self, ClientError, ConnectErrorCode, Request as DomainRequest, Response as DomainResponse,
use crate::{
actors::{
client::{
self, ClientConnection,
session::{ClientSession, Error, HandleQueryVaultState},
},
keyholder::KeyHolderState,
},
utils::defer,
};
pub struct GrpcTransport {
sender: mpsc::Sender<Result<ClientResponse, Status>>,
receiver: Streaming<ClientRequest>,
}
mod auth;
impl GrpcTransport {
pub fn new(
sender: mpsc::Sender<Result<ClientResponse, Status>>,
receiver: Streaming<ClientRequest>,
) -> Self {
Self { sender, receiver }
}
fn request_to_domain(request: ClientRequest) -> Result<DomainRequest, Status> {
match request.payload {
Some(ClientRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest {
pubkey,
})) => Ok(DomainRequest::AuthChallengeRequest { pubkey }),
Some(ClientRequestPayload::AuthChallengeSolution(ProtoAuthChallengeSolution {
signature,
})) => Ok(DomainRequest::AuthChallengeSolution { signature }),
None => Err(Status::invalid_argument("Missing client request payload")),
}
}
fn response_to_proto(response: DomainResponse) -> ClientResponse {
let payload = match response {
DomainResponse::AuthChallenge { pubkey, nonce } => {
ClientResponsePayload::AuthChallenge(ProtoAuthChallenge { pubkey, nonce })
}
DomainResponse::AuthOk => ClientResponsePayload::AuthOk(ProtoAuthOk {}),
DomainResponse::ClientConnectError { code } => {
ClientResponsePayload::ClientConnectError(ClientConnectError {
code: match code {
ConnectErrorCode::Unknown => ProtoClientConnectErrorCode::Unknown,
ConnectErrorCode::ApprovalDenied => {
ProtoClientConnectErrorCode::ApprovalDenied
}
ConnectErrorCode::NoUserAgentsOnline => {
ProtoClientConnectErrorCode::NoUserAgentsOnline
}
}
.into(),
})
}
async fn dispatch_loop(
mut bi: GrpcBi<ClientRequest, ClientResponse>,
actor: ActorRef<ClientSession>,
) {
loop {
let Some(conn) = bi.recv().await else {
return;
};
ClientResponse {
payload: Some(payload),
}
}
fn error_to_status(value: ClientError) -> Status {
match value {
ClientError::MissingRequestPayload | ClientError::UnexpectedRequestPayload => {
Status::invalid_argument("Expected message with payload")
}
ClientError::StateTransitionFailed => Status::internal("State machine error"),
ClientError::Auth(ref err) => auth_error_status(err),
ClientError::ConnectionRegistrationFailed => {
Status::internal("Connection registration failed")
}
if dispatch_conn_message(&mut bi, &actor, conn).await.is_err() {
return;
}
}
}
#[async_trait]
impl Sender<Result<DomainResponse, ClientError>> for GrpcTransport {
async fn send(
&mut self,
item: Result<DomainResponse, ClientError>,
) -> Result<(), TransportError> {
let outbound = match item {
Ok(message) => Ok(Self::response_to_proto(message)),
Err(err) => Err(Self::error_to_status(err)),
};
async fn dispatch_conn_message(
bi: &mut GrpcBi<ClientRequest, ClientResponse>,
actor: &ActorRef<ClientSession>,
conn: Result<ClientRequest, tonic::Status>,
) -> Result<(), ()> {
let conn = match conn {
Ok(conn) => conn,
Err(err) => {
warn!(error = ?err, "Failed to receive client request");
return Err(());
}
};
self.sender
.send(outbound)
.await
.map_err(|_| TransportError::ChannelClosed)
}
}
let Some(payload) = conn.payload else {
let _ = bi
.send(Err(tonic::Status::invalid_argument(
"Missing client request payload",
)))
.await;
return Err(());
};
#[async_trait]
impl Bi<DomainRequest, Result<DomainResponse, ClientError>> for GrpcTransport {
async fn recv(&mut self) -> Option<DomainRequest> {
match self.receiver.next().await {
Some(Ok(item)) => match Self::request_to_domain(item) {
Ok(request) => Some(request),
Err(status) => {
let _ = self.sender.send(Err(status)).await;
None
let payload = match payload {
ClientRequestPayload::QueryVaultState(_) => ClientResponsePayload::VaultState(
match actor.ask(HandleQueryVaultState {}).await {
Ok(KeyHolderState::Unbootstrapped) => ProtoVaultState::Unbootstrapped,
Ok(KeyHolderState::Sealed) => ProtoVaultState::Sealed,
Ok(KeyHolderState::Unsealed) => ProtoVaultState::Unsealed,
Err(SendError::HandlerError(Error::Internal)) => ProtoVaultState::Error,
Err(err) => {
warn!(error = ?err, "Failed to query vault state");
ProtoVaultState::Error
}
},
Some(Err(error)) => {
tracing::error!(error = ?error, "grpc client recv failed; closing stream");
None
}
None => None,
.into(),
),
payload => {
warn!(?payload, "Unsupported post-auth client request");
let _ = bi
.send(Err(tonic::Status::invalid_argument(
"Unsupported client request",
)))
.await;
return Err(());
}
};
bi.send(Ok(ClientResponse {
payload: Some(payload),
}))
.await
.map_err(|_| ())
}
pub async fn start(conn: ClientConnection, mut bi: GrpcBi<ClientRequest, ClientResponse>) {
let mut conn = conn;
match auth::start(&mut conn, &mut bi).await {
Ok(_) => {
let actor =
client::session::ClientSession::spawn(client::session::ClientSession::new(conn));
let actor_for_cleanup = actor.clone();
let _ = defer(move || {
actor_for_cleanup.kill();
});
info!("Client authenticated successfully");
dispatch_loop(bi, actor).await;
}
Err(e) => {
let mut transport = auth::AuthTransportAdapter(&mut bi);
let _ = transport.send(Err(e.clone())).await;
warn!(error = ?e, "Authentication failed");
return;
}
}
}
fn auth_error_status(value: &client::auth::Error) -> Status {
use client::auth::Error;
match value {
Error::UnexpectedMessagePayload | Error::InvalidClientPubkeyLength => {
Status::invalid_argument(value.to_string())
}
Error::InvalidAuthPubkeyEncoding => {
Status::invalid_argument("Failed to convert pubkey to VerifyingKey")
}
Error::InvalidChallengeSolution => Status::unauthenticated(value.to_string()),
Error::ApproveError(_) => Status::permission_denied(value.to_string()),
Error::Transport => Status::internal("Transport error"),
Error::DatabasePoolUnavailable => Status::internal("Database pool error"),
Error::DatabaseOperationFailed => Status::internal("Database error"),
Error::InternalError => Status::internal("Internal error"),
}
}

View File

@@ -0,0 +1,131 @@
use arbiter_proto::{
proto::client::{
AuthChallenge as ProtoAuthChallenge, AuthChallengeRequest as ProtoAuthChallengeRequest,
AuthChallengeSolution as ProtoAuthChallengeSolution, AuthResult as ProtoAuthResult,
ClientRequest, ClientResponse, client_request::Payload as ClientRequestPayload,
client_response::Payload as ClientResponsePayload,
},
transport::{Bi, Error as TransportError, Receiver, Sender, grpc::GrpcBi},
};
use async_trait::async_trait;
use tracing::warn;
use crate::actors::client::{self, ClientConnection, auth};
pub struct AuthTransportAdapter<'a>(pub(super) &'a mut GrpcBi<ClientRequest, ClientResponse>);
impl AuthTransportAdapter<'_> {
fn response_to_proto(response: auth::Outbound) -> ClientResponse {
let payload = match response {
auth::Outbound::AuthChallenge { pubkey, nonce } => {
ClientResponsePayload::AuthChallenge(ProtoAuthChallenge {
pubkey: pubkey.to_bytes().to_vec(),
nonce,
})
}
auth::Outbound::AuthSuccess => {
ClientResponsePayload::AuthResult(ProtoAuthResult::Success.into())
}
};
ClientResponse {
payload: Some(payload),
}
}
fn error_to_proto(error: auth::Error) -> ClientResponse {
ClientResponse {
payload: Some(ClientResponsePayload::AuthResult(
match error {
auth::Error::InvalidChallengeSolution => ProtoAuthResult::InvalidSignature,
auth::Error::ApproveError(auth::ApproveError::Denied) => {
ProtoAuthResult::ApprovalDenied
}
auth::Error::ApproveError(auth::ApproveError::Upstream(
crate::actors::router::ApprovalError::NoUserAgentsConnected,
)) => ProtoAuthResult::NoUserAgentsOnline,
auth::Error::ApproveError(auth::ApproveError::Internal)
| auth::Error::DatabasePoolUnavailable
| auth::Error::DatabaseOperationFailed
| auth::Error::Transport => ProtoAuthResult::Internal,
}
.into(),
)),
}
}
async fn send_auth_result(&mut self, result: ProtoAuthResult) -> Result<(), TransportError> {
self.0
.send(Ok(ClientResponse {
payload: Some(ClientResponsePayload::AuthResult(result.into())),
}))
.await
}
}
#[async_trait]
impl Sender<Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {
async fn send(
&mut self,
item: Result<auth::Outbound, auth::Error>,
) -> Result<(), TransportError> {
let outbound = match item {
Ok(message) => Ok(AuthTransportAdapter::response_to_proto(message)),
Err(err) => Ok(AuthTransportAdapter::error_to_proto(err)),
};
self.0.send(outbound).await
}
}
#[async_trait]
impl Receiver<auth::Inbound> for AuthTransportAdapter<'_> {
async fn recv(&mut self) -> Option<auth::Inbound> {
let request = match self.0.recv().await? {
Ok(request) => request,
Err(error) => {
warn!(error = ?error, "grpc client recv failed; closing stream");
return None;
}
};
let payload = request.payload?;
match payload {
ClientRequestPayload::AuthChallengeRequest(ProtoAuthChallengeRequest { pubkey }) => {
let Ok(pubkey) = <[u8; 32]>::try_from(pubkey) else {
let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await;
return None;
};
let Ok(pubkey) = ed25519_dalek::VerifyingKey::from_bytes(&pubkey) else {
let _ = self.send_auth_result(ProtoAuthResult::InvalidKey).await;
return None;
};
Some(auth::Inbound::AuthChallengeRequest { pubkey })
}
ClientRequestPayload::AuthChallengeSolution(ProtoAuthChallengeSolution {
signature,
}) => {
let Ok(signature) = ed25519_dalek::Signature::try_from(signature.as_slice()) else {
let _ = self
.send_auth_result(ProtoAuthResult::InvalidSignature)
.await;
return None;
};
Some(auth::Inbound::AuthChallengeSolution { signature })
}
_ => None,
}
}
}
impl Bi<auth::Inbound, Result<auth::Outbound, auth::Error>> for AuthTransportAdapter<'_> {}
pub async fn start(
conn: &mut ClientConnection,
bi: &mut GrpcBi<ClientRequest, ClientResponse>,
) -> Result<(), auth::Error> {
let mut transport = AuthTransportAdapter(bi);
client::auth::authenticate(conn, &mut transport).await?;
Ok(())
}

View File

@@ -12,10 +12,7 @@ use tracing::info;
use crate::{
DEFAULT_CHANNEL_SIZE,
actors::{
client::{ClientConnection, connect_client},
user_agent::UserAgentConnection,
},
actors::{client::ClientConnection, user_agent::UserAgentConnection},
grpc::{self, user_agent::start},
};
@@ -33,19 +30,13 @@ impl arbiter_proto::proto::arbiter_service_server::ArbiterService for super::Ser
request: Request<tonic::Streaming<ClientRequest>>,
) -> Result<Response<Self::ClientStream>, Status> {
let req_stream = request.into_inner();
let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE);
let transport = client::GrpcTransport::new(tx, req_stream);
let props = ClientConnection::new(
self.context.db.clone(),
Box::new(transport),
self.context.actors.clone(),
);
tokio::spawn(connect_client(props));
let (bi, rx) = GrpcBi::from_bi_stream(req_stream);
let props = ClientConnection::new(self.context.db.clone(), self.context.actors.clone());
tokio::spawn(client::start(props, bi));
info!(event = "connection established", "grpc.client");
Ok(Response::new(ReceiverStream::new(rx)))
Ok(Response::new(rx))
}
#[tracing::instrument(level = "debug", skip(self))]

View File

@@ -30,7 +30,10 @@ use arbiter_proto::{
};
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use kameo::{actor::{ActorRef, Spawn as _}, error::SendError};
use kameo::{
actor::{ActorRef, Spawn as _},
error::SendError,
};
use tonic::Status;
use tracing::{info, warn};
@@ -40,7 +43,9 @@ use crate::{
user_agent::{
OutOfBand, UserAgentConnection, UserAgentSession,
session::{
BootstrapError, Error, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantList, HandleQueryVaultState, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError
BootstrapError, Error, HandleBootstrapEncryptedKey, HandleEvmWalletCreate,
HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantList,
HandleQueryVaultState, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
},
},
},
@@ -109,7 +114,11 @@ async fn dispatch_conn_message(
};
let Some(payload) = conn.payload else {
let _ = bi.send(Err(Status::invalid_argument("Missing user-agent request payload"))).await;
let _ = bi
.send(Err(Status::invalid_argument(
"Missing user-agent request payload",
)))
.await;
return Err(());
};
@@ -118,7 +127,9 @@ async fn dispatch_conn_message(
let client_pubkey = match <[u8; 32]>::try_from(client_pubkey) {
Ok(bytes) => x25519_dalek::PublicKey::from(bytes),
Err(_) => {
let _ = bi.send(Err(Status::invalid_argument("Invalid X25519 public key"))).await;
let _ = bi
.send(Err(Status::invalid_argument("Invalid X25519 public key")))
.await;
return Err(());
}
};
@@ -131,7 +142,9 @@ async fn dispatch_conn_message(
),
Err(err) => {
warn!(error = ?err, "Failed to handle unseal start request");
let _ = bi.send(Err(Status::internal("Failed to start unseal flow"))).await;
let _ = bi
.send(Err(Status::internal("Failed to start unseal flow")))
.await;
return Err(());
}
}
@@ -155,7 +168,9 @@ async fn dispatch_conn_message(
}
Err(err) => {
warn!(error = ?err, "Failed to handle unseal request");
let _ = bi.send(Err(Status::internal("Failed to unseal vault"))).await;
let _ = bi
.send(Err(Status::internal("Failed to unseal vault")))
.await;
return Err(());
}
}
@@ -178,12 +193,14 @@ async fn dispatch_conn_message(
Err(SendError::HandlerError(BootstrapError::InvalidKey)) => {
ProtoBootstrapResult::InvalidKey
}
Err(SendError::HandlerError(
BootstrapError::AlreadyBootstrapped,
)) => ProtoBootstrapResult::AlreadyBootstrapped,
Err(SendError::HandlerError(BootstrapError::AlreadyBootstrapped)) => {
ProtoBootstrapResult::AlreadyBootstrapped
}
Err(err) => {
warn!(error = ?err, "Failed to handle bootstrap request");
let _ = bi.send(Err(Status::internal("Failed to bootstrap vault"))).await;
let _ = bi
.send(Err(Status::internal("Failed to bootstrap vault")))
.await;
return Err(());
}
}
@@ -224,12 +241,13 @@ async fn dispatch_conn_message(
};
UserAgentResponsePayload::EvmGrantCreate(EvmGrantOrWallet::grant_create_response(
actor.ask(HandleGrantCreate {
client_id,
basic,
grant,
})
.await,
actor
.ask(HandleGrantCreate {
client_id,
basic,
grant,
})
.await,
))
}
UserAgentRequestPayload::EvmGrantDelete(EvmGrantDeleteRequest { grant_id }) => {
@@ -239,7 +257,11 @@ async fn dispatch_conn_message(
}
payload => {
warn!(?payload, "Unsupported post-auth user agent request");
let _ = bi.send(Err(Status::invalid_argument("Unsupported user-agent request"))).await;
let _ = bi
.send(Err(Status::invalid_argument(
"Unsupported user-agent request",
)))
.await;
return Err(());
}
};
@@ -281,7 +303,10 @@ fn parse_grant_request(
let specific =
specific.ok_or_else(|| Status::invalid_argument("Missing specific grant settings"))?;
Ok((shared_settings_from_proto(shared)?, specific_grant_from_proto(specific)?))
Ok((
shared_settings_from_proto(shared)?,
specific_grant_from_proto(specific)?,
))
}
fn shared_settings_from_proto(shared: ProtoSharedSettings) -> Result<SharedGrantSettings, Status> {
@@ -289,14 +314,8 @@ fn shared_settings_from_proto(shared: ProtoSharedSettings) -> Result<SharedGrant
wallet_id: shared.wallet_id,
client_id: 0,
chain: shared.chain_id,
valid_from: shared
.valid_from
.map(proto_timestamp_to_utc)
.transpose()?,
valid_until: shared
.valid_until
.map(proto_timestamp_to_utc)
.transpose()?,
valid_from: shared.valid_from.map(proto_timestamp_to_utc).transpose()?,
valid_until: shared.valid_until.map(proto_timestamp_to_utc).transpose()?,
max_gas_fee_per_gas: shared
.max_gas_fee_per_gas
.as_deref()
@@ -307,12 +326,10 @@ fn shared_settings_from_proto(shared: ProtoSharedSettings) -> Result<SharedGrant
.as_deref()
.map(u256_from_proto_bytes)
.transpose()?,
rate_limit: shared
.rate_limit
.map(|limit| TransactionRateLimit {
count: limit.count,
window: chrono::Duration::seconds(limit.window_secs),
}),
rate_limit: shared.rate_limit.map(|limit| TransactionRateLimit {
count: limit.count,
window: chrono::Duration::seconds(limit.window_secs),
}),
})
}
@@ -326,11 +343,9 @@ fn specific_grant_from_proto(specific: ProtoSpecificGrant) -> Result<SpecificGra
.into_iter()
.map(address_from_bytes)
.collect::<Result<_, _>>()?,
limit: volume_rate_limit_from_proto(
limit.ok_or_else(|| {
Status::invalid_argument("Missing ether transfer volume rate limit")
})?,
)?,
limit: volume_rate_limit_from_proto(limit.ok_or_else(|| {
Status::invalid_argument("Missing ether transfer volume rate limit")
})?)?,
})),
Some(ProtoSpecificGrantType::TokenTransfer(ProtoTokenTransferSettings {
token_contract,
@@ -391,12 +406,12 @@ fn shared_settings_to_proto(shared: SharedGrantSettings) -> ProtoSharedSettings
seconds: time.timestamp(),
nanos: time.timestamp_subsec_nanos() as i32,
}),
max_gas_fee_per_gas: shared.max_gas_fee_per_gas.map(|value| {
value.to_be_bytes::<32>().to_vec()
}),
max_priority_fee_per_gas: shared.max_priority_fee_per_gas.map(|value| {
value.to_be_bytes::<32>().to_vec()
}),
max_gas_fee_per_gas: shared
.max_gas_fee_per_gas
.map(|value| value.to_be_bytes::<32>().to_vec()),
max_priority_fee_per_gas: shared
.max_priority_fee_per_gas
.map(|value| value.to_be_bytes::<32>().to_vec()),
rate_limit: shared.rate_limit.map(|limit| ProtoTransactionRateLimit {
count: limit.count,
window_secs: limit.window.num_seconds(),
@@ -408,7 +423,11 @@ fn specific_grant_to_proto(grant: SpecificGrant) -> ProtoSpecificGrant {
let grant = match grant {
SpecificGrant::EtherTransfer(settings) => {
ProtoSpecificGrantType::EtherTransfer(ProtoEtherTransferSettings {
targets: settings.target.into_iter().map(|address| address.to_vec()).collect(),
targets: settings
.target
.into_iter()
.map(|address| address.to_vec())
.collect(),
limit: Some(ProtoVolumeRateLimit {
max_volume: settings.limit.max_volume.to_be_bytes::<32>().to_vec(),
window_secs: settings.limit.window.num_seconds(),
@@ -450,7 +469,9 @@ impl EvmGrantOrWallet {
}
};
WalletCreateResponse { result: Some(result) }
WalletCreateResponse {
result: Some(result),
}
}
fn wallet_list_response<M>(
@@ -471,7 +492,9 @@ impl EvmGrantOrWallet {
}
};
WalletListResponse { result: Some(result) }
WalletListResponse {
result: Some(result),
}
}
fn grant_create_response<M>(
@@ -485,12 +508,12 @@ impl EvmGrantOrWallet {
}
};
EvmGrantCreateResponse { result: Some(result) }
EvmGrantCreateResponse {
result: Some(result),
}
}
fn grant_delete_response<M>(
result: Result<(), SendError<M, Error>>,
) -> EvmGrantDeleteResponse {
fn grant_delete_response<M>(result: Result<(), SendError<M, Error>>) -> EvmGrantDeleteResponse {
let result = match result {
Ok(()) => EvmGrantDeleteResult::Ok(()),
Err(err) => {
@@ -499,7 +522,9 @@ impl EvmGrantOrWallet {
}
};
EvmGrantDeleteResponse { result: Some(result) }
EvmGrantDeleteResponse {
result: Some(result),
}
}
fn grant_list_response<M>(
@@ -523,7 +548,9 @@ impl EvmGrantOrWallet {
}
};
EvmGrantListResponse { result: Some(result) }
EvmGrantListResponse {
result: Some(result),
}
}
}