client: turn Client inside out

Since the WASM client cannot actually use a `http::Request` the way we
expect it to, that is, it cannot manually along cookies, we turn the
client bit inside out:

This crate mainly defines the `HttpApiClient` trait which expects the
http client to perform *authenticated* API calls, that is, the
handling of API tokens and tickets should happen at the *implementor*
side.

The product clients will require *this* trait to be implemented, and
will not themselves offer a way to login.

As for the `Client` struct, this will now instead *implement* this
trait and will *not* be used in the `wasm` ecosystem. Rather, this is
the ticket handling http client that already exists in the PWT based
ui code.

The PVE client in `pve-api-types` will not *contain* a `Client`
anymore, but rather, it will provide PVE api call implementations for
something implementing `HttpApiClient`.

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
Wolfgang Bumiller 2023-08-08 11:03:58 +02:00
parent 0f19f2125f
commit 1c96afd0ec
3 changed files with 377 additions and 258 deletions

View File

@ -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<Output = Result<Response<Vec<u8>>, Error>>;
use super::{HttpApiClient, HttpApiResponse};
fn request(&self, request: Request<Vec<u8>>) -> Self::ResponseFuture;
#[allow(clippy::type_complexity)]
type ResponseFuture = Pin<Box<dyn Future<Output = Result<HttpApiResponse, Error>> + Send>>;
#[derive(Default)]
pub enum TlsOptions {
/// Default TLS verification.
#[default]
Verify,
/// Insecure: ignore invalid certificates.
Insecure,
/// Expect a specific certificate fingerprint.
Fingerprint(Vec<u8>),
/// Verify with a specific PEM formatted CA.
CaCert(X509),
/// Use a callback for certificate verification.
Callback(Box<dyn Fn(bool, &mut x509::X509StoreContextRef) -> bool + Send + Sync + 'static>),
}
/// Proxmox VE high level API client.
pub struct Client<C> {
/// A Proxmox API client base backed by a [`proxmox_http::Client`].
pub struct Client {
api_url: Uri,
auth: Mutex<Option<Arc<AuthenticationKind>>>,
client: C,
client: Arc<proxmox_http::client::Client>,
pve_compat: bool,
}
impl<C> Client<C> {
/// 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<Arc<AuthenticationKind>> {
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<http::Request<Vec<u8>>, 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<C> Client<C> {
/// 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<C> Client<C>
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<proxmox_http::client::Client>) -> 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<Self, Error> {
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<proxmox_http::client::Client> {
&self.client
}
/// Get a reference to the current authentication information.
pub fn authentication(&self) -> Option<Arc<AuthenticationKind>> {
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<Uri, Error> {
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::<PathAndQuery>()
.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<proxmox_http::client::Client>,
auth: Arc<AuthenticationKind>,
method: http::Method,
uri: Uri,
json_body: Option<String>,
) -> Result<HttpApiResponse, Error> {
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<Arc<AuthenticationKind>, 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<Validity, Error> {
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<Vec<u8>, Error> {
let mut data = Vec::<u8>::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<T>(&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<http::Request<Body>, 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<http::request::Builder, Error> {
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<Uri, Error> {
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::<PathAndQuery>()
.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<ApiResponse<R>, 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<T> RawApiResponse<T> {
}
}
#[cfg(feature = "hyper-client")]
pub type HyperClient = Client<Arc<proxmox_http::client::Client>>;
#[cfg(feature = "hyper-client")]
impl<C> Client<C> {
/// 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<http::request::Builder, Error> {
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<u8>),
/// Verify with a specific PEM formatted CA.
CaCert(X509),
/// Use a callback for certificate verification.
Callback(Box<dyn Fn(bool, &mut x509::X509StoreContextRef) -> 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<C> Client<C> {
/// 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<HyperClient, Error> {
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<proxmox_http::client::Client> {
#[allow(clippy::type_complexity)]
type ResponseFuture =
std::pin::Pin<Box<dyn Future<Output = Result<Response<Vec<u8>>, Error>> + Send>>;
fn request(&self, request: Request<Vec<u8>>) -> Self::ResponseFuture {
let (parts, body) = request.into_parts();
let request = Request::<hyper::Body>::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::<u8>::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;
*/

View File

@ -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),

View File

@ -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<u8>,
}
/// 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<Output = Result<HttpApiResponse, Error>>;
/// `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<T>(&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;
}