diff --git a/proxmox-client/src/client.rs b/proxmox-client/src/client.rs index 79f44a9c..499490cd 100644 --- a/proxmox-client/src/client.rs +++ b/proxmox-client/src/client.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::fmt; use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use std::sync::Mutex; @@ -8,78 +9,59 @@ use http::request::Request; use http::response::Response; use http::uri::PathAndQuery; use http::{StatusCode, Uri}; +use hyper::body::{Body, HttpBody}; +use openssl::hash::MessageDigest; +use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode}; +use openssl::x509::{self, X509}; +use serde::Serialize; use serde_json::Value; +use proxmox_login::ticket::Validity; use proxmox_login::{Login, SecondFactorChallenge, TicketResult}; use crate::auth::AuthenticationKind; use crate::{Error, Token}; -/// HTTP client backend trait. -/// -/// An async [`Client`] requires some kind of async HTTP client implementation. -pub trait HttpClient: Send + Sync { - type ResponseFuture: Future>, Error>>; +use super::{HttpApiClient, HttpApiResponse}; - fn request(&self, request: Request>) -> Self::ResponseFuture; +#[allow(clippy::type_complexity)] +type ResponseFuture = Pin> + Send>>; + +#[derive(Default)] +pub enum TlsOptions { + /// Default TLS verification. + #[default] + Verify, + + /// Insecure: ignore invalid certificates. + Insecure, + + /// Expect a specific certificate fingerprint. + Fingerprint(Vec), + + /// Verify with a specific PEM formatted CA. + CaCert(X509), + + /// Use a callback for certificate verification. + Callback(Box bool + Send + Sync + 'static>), } -/// Proxmox VE high level API client. -pub struct Client { +/// A Proxmox API client base backed by a [`proxmox_http::Client`]. +pub struct Client { api_url: Uri, auth: Mutex>>, - client: C, + client: Arc, pve_compat: bool, } -impl Client { - /// Get the underlying client object. - pub fn inner(&self) -> &C { - &self.client +impl Client { + /// Create a new client instance which will connect to the provided endpoint. + pub fn new(api_url: Uri) -> Self { + Client::with_client(api_url, Arc::new(proxmox_http::client::Client::new())) } - /// Get a mutable reference to the underlying client object. - pub fn inner_mut(&mut self) -> &mut C { - &mut self.client - } - - /// Get a reference to the current authentication information. - pub fn authentication(&self) -> Option> { - self.auth.lock().unwrap().clone() - } - - pub fn use_api_token(&self, token: Token) { - *self.auth.lock().unwrap() = Some(Arc::new(token.into())); - } -} - -fn to_request(request: proxmox_login::Request) -> Result>, Error> { - http::Request::builder() - .method(http::Method::POST) - .uri(request.url) - .header(http::header::CONTENT_TYPE, request.content_type) - .header( - http::header::CONTENT_LENGTH, - request.content_length.to_string(), - ) - .body(request.body.into_bytes()) - .map_err(|err| Error::internal("error building login http request", err)) -} - -impl Client { - /// Enable Proxmox VE login API compatibility. This is required to support TFA authentication - /// on Proxmox VE APIs which require the `new-format` option. - pub fn set_pve_compatibility(&mut self, compatibility: bool) { - self.pve_compat = compatibility; - } -} - -impl Client -where - C: HttpClient, -{ /// Instantiate a client for an API with a given HTTP client instance. - pub fn with_client(api_url: Uri, client: C) -> Self { + pub fn with_client(api_url: Uri, client: Arc) -> Self { Self { api_url, auth: Mutex::new(None), @@ -88,8 +70,139 @@ where } } + /// Create a new client instance which will connect to the provided endpoint. + pub fn with_options( + api_url: Uri, + tls_options: TlsOptions, + http_options: proxmox_http::HttpOptions, + ) -> Result { + let mut connector = SslConnector::builder(SslMethod::tls_client()) + .map_err(|err| Error::internal("failed to create ssl connector builder", err))?; + + match tls_options { + TlsOptions::Verify => (), + TlsOptions::Insecure => connector.set_verify(SslVerifyMode::NONE), + TlsOptions::Fingerprint(expected_fingerprint) => { + connector.set_verify_callback(SslVerifyMode::PEER, move |valid, chain| { + if valid { + return true; + } + verify_fingerprint(chain, &expected_fingerprint) + }); + } + TlsOptions::Callback(cb) => { + connector + .set_verify_callback(SslVerifyMode::PEER, move |valid, chain| cb(valid, chain)); + } + TlsOptions::CaCert(ca) => { + let mut store = openssl::x509::store::X509StoreBuilder::new().map_err(|err| { + Error::internal("failed to create certificate store builder", err) + })?; + store + .add_cert(ca) + .map_err(|err| Error::internal("failed to build certificate store", err))?; + connector.set_cert_store(store.build()); + } + } + + let client = + proxmox_http::client::Client::with_ssl_connector(connector.build(), http_options); + + Ok(Self::with_client(api_url, Arc::new(client))) + } + + /// Get the underlying client object. + pub fn http_client(&self) -> &Arc { + &self.client + } + + /// Get a reference to the current authentication information. + pub fn authentication(&self) -> Option> { + self.auth.lock().unwrap().clone() + } + + /// Replace the authentication information with an API token. + pub fn use_api_token(&self, token: Token) { + *self.auth.lock().unwrap() = Some(Arc::new(token.into())); + } + + /// Drop the current authentication information. + pub fn logout(&self) { + self.auth.lock().unwrap().take(); + } + + /// Enable Proxmox VE login API compatibility. This is required to support TFA authentication + /// on Proxmox VE APIs which require the `new-format` option. + pub fn set_pve_compatibility(&mut self, compatibility: bool) { + self.pve_compat = compatibility; + } + + /// Get the currently used API url. + pub fn api_url(&self) -> &Uri { + &self.api_url + } + + /// Build a URI relative to the current API endpoint. + fn build_uri(&self, path_and_query: &str) -> Result { + let parts = self.api_url.clone().into_parts(); + let mut builder = http::uri::Builder::new(); + if let Some(scheme) = parts.scheme { + builder = builder.scheme(scheme); + } + if let Some(authority) = parts.authority { + builder = builder.authority(authority) + } + builder + .path_and_query( + path_and_query + .parse::() + .map_err(|err| Error::internal("failed to parse uri", err))?, + ) + .build() + .map_err(|err| Error::internal("failed to build Uri", err)) + } + + /// Perform an *unauthenticated* HTTP request. + async fn authenticated_request( + client: Arc, + auth: Arc, + method: http::Method, + uri: Uri, + json_body: Option, + ) -> Result { + let request = auth + .set_auth_headers(Request::builder().method(method).uri(uri)) + .body(json_body.unwrap_or_default().into()) + .map_err(|err| Error::internal("failed to build request", err))?; + + let response = client.request(request).await.map_err(Error::Anyhow)?; + + if response.status() == StatusCode::UNAUTHORIZED { + return Err(Error::Unauthorized); + } + + let (response, body) = response.into_parts(); + let body = read_body(body).await?; + + if !response.status.is_success() { + // FIXME: Decode json errors... + //match serde_json::from_slice(&data) + // Ok(value) => + // if value["error"] + let data = + String::from_utf8(body).map_err(|_| Error::Other("API returned non-utf8 data"))?; + + return Err(Error::api(response.status, data)); + } + + Ok(HttpApiResponse { + status: response.status.as_u16(), + body, + }) + } + /// Assert that we are authenticated and return the `AuthenticationKind`. - /// Otherwise returns `Error::Unauthenticated`. + /// Otherwise returns `Error::Unauthorized`. pub fn login_auth(&self) -> Result, Error> { self.auth .lock() @@ -98,6 +211,166 @@ where .ok_or_else(|| Error::Unauthorized) } + /// Check to see if we need to refresh the ticket. Note that it is an error to call this when + /// logged out, which will return `Error::Unauthorized`. + /// + /// Tokens are always valid. + pub fn ticket_validity(&self) -> Result { + match &*self.login_auth()? { + AuthenticationKind::Token(_) => Ok(Validity::Valid), + AuthenticationKind::Ticket(auth) => Ok(auth.ticket.validity()), + } + } + + /// If the ticket expires soon (has a validity of [`Validity::Refresh`]), this will attempt to + /// refresh the ticket. + pub async fn maybe_refresh_ticket(&self) -> Result<(), Error> { + if let Validity::Refresh = self.ticket_validity()? { + self.refresh_ticket().await?; + } + + Ok(()) + } + + /// Attempt to refresh the current ticket. + /// + /// If not logged in at all yet, `Error::Unauthorized` will be returned. + pub async fn refresh_ticket(&self) -> Result<(), Error> { + let auth = self.login_auth()?; + let auth = match &*auth { + AuthenticationKind::Token(_) => return Ok(()), + AuthenticationKind::Ticket(auth) => auth, + }; + + let login = Login::renew(self.api_url.to_string(), auth.ticket.to_string()) + .map_err(Error::Ticket)?; + let request = login_to_request(login.request())?; + + let response = self.client.request(request).await.map_err(Error::Anyhow)?; + if !response.status().is_success() { + return Err(Error::api(response.status(), "authentication failed")); + } + + let (_, body) = response.into_parts(); + let body = read_body(body).await?; + match login.response(&body)? { + TicketResult::Full(auth) => { + *self.auth.lock().unwrap() = Some(Arc::new(auth.into())); + Ok(()) + } + TicketResult::TfaRequired(_) => Err(proxmox_login::error::ResponseError::Msg( + "ticket refresh returned a TFA challenge", + ) + .into()), + } + } +} + +async fn read_body(mut body: Body) -> Result, Error> { + let mut data = Vec::::new(); + while let Some(more) = body.data().await { + let more = more.map_err(|err| Error::internal("error reading response body", err))?; + data.extend(&more[..]); + } + Ok(data) +} + +impl HttpApiClient for Client { + type ResponseFuture = ResponseFuture; + + fn get(&self, path_and_query: &str) -> Self::ResponseFuture { + let client = Arc::clone(&self.client); + let request_params = self + .login_auth() + .and_then(|auth| self.build_uri(path_and_query).map(|uri| (auth, uri))); + Box::pin(async move { + let (auth, uri) = request_params?; + Self::authenticated_request(client, auth, http::Method::GET, uri, None).await + }) + } + + fn post(&self, path_and_query: &str, params: &T) -> Self::ResponseFuture + where + T: ?Sized + Serialize, + { + let client = Arc::clone(&self.client); + let request_params = self + .login_auth() + .and_then(|auth| self.build_uri(path_and_query).map(|uri| (auth, uri))) + .and_then(|(auth, uri)| { + serde_json::to_string(params) + .map_err(|err| Error::internal("failed to serialize parametres", err)) + .map(|params| (auth, uri, params)) + }); + Box::pin(async move { + let (auth, uri, params) = request_params?; + Self::authenticated_request(client, auth, http::Method::POST, uri, Some(params)).await + }) + } + + fn delete(&self, path_and_query: &str) -> Self::ResponseFuture { + let client = Arc::clone(&self.client); + let request_params = self + .login_auth() + .and_then(|auth| self.build_uri(path_and_query).map(|uri| (auth, uri))); + Box::pin(async move { + let (auth, uri) = request_params?; + Self::authenticated_request(client, auth, http::Method::DELETE, uri, None).await + }) + } +} + +fn login_to_request(request: proxmox_login::Request) -> Result, Error> { + http::Request::builder() + .method(http::Method::POST) + .uri(request.url) + .header(http::header::CONTENT_TYPE, request.content_type) + .header( + http::header::CONTENT_LENGTH, + request.content_length.to_string(), + ) + .body(request.body.into()) + .map_err(|err| Error::internal("error building login http request", err)) +} + +fn verify_fingerprint(chain: &x509::X509StoreContextRef, expected_fingerprint: &[u8]) -> bool { + let Some(cert) = chain.current_cert() else { + log::error!("no certificate in chain?"); + return false; + }; + + let fp = match cert.digest(MessageDigest::sha256()) { + Err(err) => { + log::error!("error calculating certificate fingerprint: {err}"); + return false; + } + Ok(fp) => fp, + }; + + if expected_fingerprint != fp.as_ref() { + log::error!("bad fingerprint: {}", fp_string(&fp)); + log::error!("expected fingerprint: {}", fp_string(&expected_fingerprint)); + return false; + } + + true +} + +fn fp_string(fp: &[u8]) -> String { + use std::fmt::Write as _; + + let mut out = String::new(); + for b in fp { + if !out.is_empty() { + out.push(':'); + } + let _ = write!(out, "{b:02x}"); + } + out +} + +/* +impl Client { /// If currently logged in, this will fill in the auth cookie and CSRFPreventionToken header /// and return `Ok(request)`, otherwise it'll return `Err(request)` with the request /// unmodified. @@ -112,14 +385,6 @@ where } } - /// Convenience method to login and set the authentication headers for a request. - pub async fn set_auth_headers( - &self, - request: http::request::Builder, - ) -> Result { - Ok(self.login_auth()?.set_auth_headers(request)) - } - /// Attempt to login. /// /// This will propagate the PVE compatibility state and then perform the `Login` request via @@ -167,30 +432,6 @@ where Ok(()) } - /// Get the currently used API url. - pub fn api_url(&self) -> &Uri { - &self.api_url - } - - /// Build a URI relative to the current API endpoint. - fn build_uri(&self, path: &str) -> Result { - let parts = self.api_url.clone().into_parts(); - let mut builder = http::uri::Builder::new(); - if let Some(scheme) = parts.scheme { - builder = builder.scheme(scheme); - } - if let Some(authority) = parts.authority { - builder = builder.authority(authority) - } - builder - .path_and_query( - path.parse::() - .map_err(|err| Error::internal("failed to parse uri", err))?, - ) - .build() - .map_err(|err| Error::internal("failed to build Uri", err)) - } - /// Execute a `GET` request, possibly trying multiple cluster nodes. pub async fn get<'a, R>(&'a self, uri: &str) -> Result, Error> where @@ -288,7 +529,7 @@ where .await } - /// Helper method for a request with a byte body, yieldinig a JSON result of type `R`. + /// Helper method for a request with a byte body, yielding a JSON result of type `R`. async fn json_request_bytes<'a, R>( &'a self, auth: &AuthenticationKind, @@ -438,163 +679,15 @@ impl RawApiResponse { } } -#[cfg(feature = "hyper-client")] -pub type HyperClient = Client>; -#[cfg(feature = "hyper-client")] -impl Client { - /// Create a new client instance which will connect to the provided endpoint. - pub fn new(api_url: Uri) -> HyperClient { - Client::with_client(api_url, Arc::new(proxmox_http::client::Client::new())) + +impl Client { + /// Convenience method to login and set the authentication headers for a request. + pub fn set_auth_headers( + &self, + request: http::request::Builder, + ) -> Result { + Ok(self.login_auth()?.set_auth_headers(request)) } } - -#[cfg(feature = "hyper-client")] -mod hyper_client_extras { - use std::future::Future; - use std::sync::Arc; - - use http::request::Request; - use http::response::Response; - use http::Uri; - use openssl::hash::MessageDigest; - use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode}; - use openssl::x509::{self, X509}; - - use proxmox_http::client::Client as ProxmoxClient; - - use super::{Client, HyperClient}; - use crate::Error; - - #[derive(Default)] - pub enum TlsOptions { - /// Default TLS verification. - #[default] - Verify, - - /// Insecure: ignore invalid certificates. - Insecure, - - /// Expect a specific certificate fingerprint. - Fingerprint(Vec), - - /// Verify with a specific PEM formatted CA. - CaCert(X509), - - /// Use a callback for certificate verification. - Callback(Box bool + Send + Sync + 'static>), - } - - fn fp_string(fp: &[u8]) -> String { - use std::fmt::Write as _; - - let mut out = String::new(); - for b in fp { - if !out.is_empty() { - out.push(':'); - } - let _ = write!(out, "{b:02x}"); - } - out - } - - fn verify_fingerprint(chain: &x509::X509StoreContextRef, expected_fingerprint: &[u8]) -> bool { - let Some(cert) = chain.current_cert() else { - log::error!("no certificate in chain?"); - return false; - }; - - let fp = match cert.digest(MessageDigest::sha256()) { - Err(err) => { - log::error!("error calculating certificate fingerprint: {err}"); - return false; - } - Ok(fp) => fp, - }; - - if expected_fingerprint != fp.as_ref() { - log::error!("bad fingerprint: {}", fp_string(&fp)); - log::error!("expected fingerprint: {}", fp_string(&expected_fingerprint)); - return false; - } - - true - } - - impl Client { - /// Create a new client instance which will connect to the provided endpoint. - pub fn with_options( - api_url: Uri, - tls_options: TlsOptions, - http_options: proxmox_http::HttpOptions, - ) -> Result { - let mut connector = SslConnector::builder(SslMethod::tls_client()) - .map_err(|err| Error::internal("failed to create ssl connector builder", err))?; - - match tls_options { - TlsOptions::Verify => (), - TlsOptions::Insecure => connector.set_verify(SslVerifyMode::NONE), - TlsOptions::Fingerprint(expected_fingerprint) => { - connector.set_verify_callback(SslVerifyMode::PEER, move |valid, chain| { - if valid { - return true; - } - verify_fingerprint(chain, &expected_fingerprint) - }); - } - TlsOptions::Callback(cb) => { - connector.set_verify_callback(SslVerifyMode::PEER, move |valid, chain| { - cb(valid, chain) - }); - } - TlsOptions::CaCert(ca) => { - let mut store = - openssl::x509::store::X509StoreBuilder::new().map_err(|err| { - Error::internal("failed to create certificate store builder", err) - })?; - store - .add_cert(ca) - .map_err(|err| Error::internal("failed to build certificate store", err))?; - connector.set_cert_store(store.build()); - } - } - - let client = ProxmoxClient::with_ssl_connector(connector.build(), http_options); - - Ok(Client::with_client(api_url, Arc::new(client))) - } - } - - impl super::HttpClient for Arc { - #[allow(clippy::type_complexity)] - type ResponseFuture = - std::pin::Pin>, Error>> + Send>>; - - fn request(&self, request: Request>) -> Self::ResponseFuture { - let (parts, body) = request.into_parts(); - let request = Request::::from_parts(parts, body.into()); - let this = Arc::clone(self); - Box::pin(async move { - use hyper::body::HttpBody; - - // FIXME: proxmox_http's client needs a way to return http status codes and such... - let (response, mut body) = (*this) - .request(request) - .await - .map_err(Error::Anyhow)? - .into_parts(); - - let mut data = Vec::::new(); - while let Some(more) = body.data().await { - let more = more.map_err(|err| Error::internal("error reading body", err))?; - data.extend(&more[..]); - } - - Ok::<_, Error>(Response::from_parts(response, data)) - }) - } - } -} - -#[cfg(feature = "hyper-client")] -pub use hyper_client_extras::TlsOptions; +*/ diff --git a/proxmox-client/src/error.rs b/proxmox-client/src/error.rs index 0d56daf9..3f2bbccb 100644 --- a/proxmox-client/src/error.rs +++ b/proxmox-client/src/error.rs @@ -4,13 +4,6 @@ use std::fmt::{self, Display}; #[derive(Debug)] #[non_exhaustive] pub enum Error { - /// The environment did not provide a way to get a 2nd factor. - TfaNotSupported, - - /// The task API wants to poll for completion of a task at regular intervals, for this it needs - /// to sleep. This signals that the environment does not support that. - SleepNotSupported, - /// Tried to make an API call without a ticket. Unauthorized, @@ -52,8 +45,6 @@ impl StdError for Error { impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - Self::TfaNotSupported => f.write_str("tfa not supported by environment"), - Self::SleepNotSupported => f.write_str("environment does not support sleeping"), Self::Unauthorized => f.write_str("unauthorized"), Self::Api(status, msg) => write!(f, "api error (status = {status}): {msg}"), Self::Other(err) => f.write_str(err), diff --git a/proxmox-client/src/lib.rs b/proxmox-client/src/lib.rs index 73b21201..939191b1 100644 --- a/proxmox-client/src/lib.rs +++ b/proxmox-client/src/lib.rs @@ -1,3 +1,7 @@ +use std::future::Future; + +use serde::Serialize; + mod error; pub use error::Error; @@ -8,8 +12,39 @@ pub use proxmox_login::{Authentication, Ticket}; pub(crate) mod auth; pub use auth::Token; -mod client; -pub use client::{ApiResponse, Client, HttpClient}; - #[cfg(feature = "hyper-client")] -pub use client::{HyperClient, TlsOptions}; +mod client; +#[cfg(feature = "hyper-client")] +pub use client::{Client, TlsOptions}; + +/// A response from the HTTP API as required by the [`HttpApiClient`] trait. +pub struct HttpApiResponse { + pub status: u16, + pub body: Vec, +} + +/// HTTP client backend trait. This should be implemented for a HTTP client capable of making +/// *authenticated* API requests to a proxmox HTTP API. +pub trait HttpApiClient: Send + Sync { + /// An API call should return a status code and the raw body. + type ResponseFuture: Future>; + + /// `GET` request with a path and query component (no hostname). + /// + /// For this request, authentication headers should be set! + fn get(&self, path_and_query: &str) -> Self::ResponseFuture; + + /// `POST` request with a path and query component (no hostname), and a serializable body. + /// + /// The body should be serialized to json and sent with `Content-type: applicaion/json`. + /// + /// For this request, authentication headers should be set! + fn post(&self, path_and_query: &str, params: &T) -> Self::ResponseFuture + where + T: ?Sized + Serialize; + + /// `DELETE` request with a path and query component (no hostname). + /// + /// For this request, authentication headers should be set! + fn delete(&self, path_and_query: &str) -> Self::ResponseFuture; +}