use std::time::Duration;
use auv_api_proto::auv::api::daemon::v1 as proto;
use crate::client::Client;
use crate::error::{ClientError, ClientErrorKind};
use crate::profile::{DeviceProfileInput, ProfileStore};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EnrollmentRequest {
pub endpoint: String,
pub token: String,
pub client_device_id: Option<String>,
pub label: String,
pub profile: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Enrollment {
pub device_id: crate::resource::DeviceId,
pub device_name: String,
pub endpoint: String,
pub profile: String,
pub credentials_file: std::path::PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum PairingError {
#[error("pairing request is unauthorized: {0}")]
Unauthorized(#[source] ClientError),
#[error("paired Device was not found: {0}")]
NotFound(#[source] ClientError),
#[error("paired Device selector is ambiguous: {0}")]
Ambiguous(#[source] ClientError),
#[error("pairing request is invalid: {0}")]
InvalidRequest(#[source] ClientError),
#[error("pairing state conflicts with the request: {0}")]
Conflict(#[source] ClientError),
#[error("pairing is not configured: {0}")]
NotConfigured(#[source] ClientError),
#[error("pairing service is unavailable: {0}")]
Unavailable(#[source] ClientError),
#[error("pairing protocol failed: {0}")]
Protocol(#[source] ClientError),
#[error("paired Device selector must not be empty")]
EmptySelector,
#[error("pairing endpoint is invalid: {0}")]
InvalidEndpoint(String),
#[error("pairing token lifetime exceeds the supported range")]
InvalidLifetime,
#[error("failed to generate a client Device identity: {0}")]
IdentityGeneration(String),
#[error("paired endpoint returned {0} caller-local Devices; expected exactly one")]
InvalidRemoteDeviceCount(usize),
#[error(transparent)]
Device(#[from] crate::devices::DeviceError),
#[error(transparent)]
Profile(#[from] crate::profile::ProfileError),
#[error("failed to connect to paired Device: {0}")]
Connect(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PairedDeviceSelector(String);
impl PairedDeviceSelector {
pub fn parse(value: &str) -> Result<Self, PairingError> {
let value = value.trim();
if value.is_empty() {
return Err(PairingError::EmptySelector);
}
Ok(Self(value.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<ClientError> for PairingError {
fn from(error: ClientError) -> Self {
match error.kind() {
ClientErrorKind::Unauthorized => Self::Unauthorized(error),
ClientErrorKind::NotFound => Self::NotFound(error),
ClientErrorKind::Ambiguous => Self::Ambiguous(error),
ClientErrorKind::InvalidRequest => Self::InvalidRequest(error),
ClientErrorKind::Conflict => Self::Conflict(error),
ClientErrorKind::Unavailable => Self::Unavailable(error),
ClientErrorKind::Unsupported => Self::NotConfigured(error),
ClientErrorKind::Protocol => Self::Protocol(error),
}
}
}
#[derive(Clone, Debug)]
pub struct Pairing {
client: Client,
}
impl Pairing {
pub(crate) fn new(client: Client) -> Self {
Self { client }
}
pub async fn create_token(&self, lifetime: Option<Duration>) -> Result<String, PairingError> {
let ttl = lifetime
.map(|duration| -> Result<prost_types::Duration, PairingError> {
Ok(prost_types::Duration {
seconds: i64::try_from(duration.as_secs()).map_err(|_| PairingError::InvalidLifetime)?,
nanos: i32::try_from(duration.subsec_nanos()).expect("subsecond nanoseconds fit i32"),
})
})
.transpose()?;
Ok(
self
.client
.grpc_client()
.pairing()
.create_pairing_token(proto::CreatePairingTokenRequest { ttl })
.await
.map_err(|status| ClientError::from_status("CreatePairingToken", status))?
.token,
)
}
pub async fn set_enabled(&self, selector: &PairedDeviceSelector, enabled: bool) -> Result<bool, PairingError> {
self
.client
.grpc_client()
.pairing()
.set_enabled(selector.as_str(), enabled)
.await
.map_err(|status| ClientError::from_status("SetPairedDeviceEnabled", status).into())
}
pub async fn unpair(&self, selector: &PairedDeviceSelector) -> Result<bool, PairingError> {
self
.client
.grpc_client()
.pairing()
.unpair(selector.as_str())
.await
.map_err(|status| ClientError::from_status("UnpairDevice", status).into())
}
pub async fn revoke_credentials(&self, selector: &PairedDeviceSelector) -> Result<bool, PairingError> {
self
.client
.grpc_client()
.pairing()
.revoke_device_credential(selector.as_str())
.await
.map_err(|status| ClientError::from_status("RevokeDeviceCredential", status).into())
}
pub async fn enroll(request: EnrollmentRequest, profiles: &ProfileStore) -> Result<Enrollment, PairingError> {
let endpoint = request.endpoint.parse::<http::Uri>().map_err(|error| PairingError::InvalidEndpoint(error.to_string()))?;
let client_device_id = request.client_device_id.unwrap_or(random_identity()?);
let response = auv_api_client::protocol::grpc::clients::daemon::v1::pairing::Client::pair_device(
endpoint.clone(),
proto::PairDeviceRequest {
token: request.token,
device_id: client_device_id,
label: request.label,
},
)
.await
.map_err(|status| ClientError::from_status("PairDevice", status))?;
let grpc = auv_api_client::protocol::grpc::Client::connect_paired(auv_api_client::PairedConnectConfig {
endpoint,
device_credential: response.device_credential.clone(),
})
.await
.map_err(|error| PairingError::Connect(error.to_string()))?;
let client = Client::from_grpc(grpc);
let mut remotes = client.devices().list().await?.into_iter().filter(|device| device.local).collect::<Vec<_>>();
if remotes.len() != 1 {
return Err(PairingError::InvalidRemoteDeviceCount(remotes.len()));
}
let remote = remotes.pop().expect("one caller-local Device");
let configured = match profiles.list_devices() {
Ok(configured) => configured,
Err(crate::profile::ProfileError::Open { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
};
let profile = request
.profile
.or_else(|| {
configured.into_iter().find(|candidate| candidate.device_id() == remote.id.as_str()).map(|value| value.config_profile().to_string())
})
.unwrap_or_else(|| {
if remote.name.is_empty() {
remote.id.short()
} else {
remote.name.clone()
}
});
profiles.upsert(
&profile,
DeviceProfileInput {
device_id: remote.id.to_string(),
device_name: remote.name.clone(),
endpoint: request.endpoint.clone(),
device_credential: response.device_credential,
},
)?;
Ok(Enrollment {
device_id: remote.id,
device_name: remote.name,
endpoint: request.endpoint,
profile,
credentials_file: profiles.config_path().to_path_buf(),
})
}
}
fn random_identity() -> Result<String, PairingError> {
let mut bytes = [0_u8; 16];
getrandom::fill(&mut bytes).map_err(|error| PairingError::IdentityGeneration(error.to_string()))?;
Ok(hex::encode(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pairing_maps_authorization_and_ambiguity_to_resource_specific_errors() {
let unauthorized = PairingError::from(ClientError::from_status("UnpairDevice", tonic::Status::permission_denied("denied")));
assert!(matches!(unauthorized, PairingError::Unauthorized(_)));
let ambiguous = PairingError::from(ClientError::from_status("SetPairedDeviceEnabled", tonic::Status::failed_precondition("ambiguous")));
assert!(matches!(ambiguous, PairingError::Ambiguous(_)));
let not_configured =
PairingError::from(ClientError::from_status("CreatePairingToken", tonic::Status::unimplemented("pairing is not configured")));
assert!(matches!(not_configured, PairingError::NotConfigured(_)));
}
#[test]
fn paired_device_selector_rejects_blank_input() {
assert!(matches!(PairedDeviceSelector::parse(" "), Err(PairingError::EmptySelector)));
}
}