proxmox/proxmox-client/src/client.rs
Shannon Sterz bedf92c0df client: add compatibility with HttpOnly cookies
this should make it possible to use the proxmox-client crate outside
of context where HttpOnly cookies are handled for us. if a cookie
name is provided to a client, it tries to find a corresponding
`Set-Cookie` header in the login response and passes tries to parse
it as a ticket. that ticket is then passed on to proxmox-login like
any regular ticket.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
2025-03-04 15:53:29 +01:00

585 lines
19 KiB
Rust

use std::error::Error as StdError;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;
use http::request::Request;
use http::uri::PathAndQuery;
use http::Method;
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 proxmox_login::Ticket;
use serde::Serialize;
use proxmox_login::ticket::Validity;
use proxmox_login::{Login, SecondFactorChallenge, TicketResult};
use crate::auth::AuthenticationKind;
use crate::error::ParseFingerprintError;
use crate::{Error, Token};
use super::{HttpApiClient, HttpApiResponse, HttpApiResponseStream};
/// See [`set_verify_callback`](openssl::ssl::SslContextBuilder::set_verify_callback()).
pub type TlsCallback = dyn Fn(bool, &mut x509::X509StoreContextRef) -> bool + Send + Sync + 'static;
#[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<TlsCallback>),
}
impl TlsOptions {
pub fn parse_fingerprint(fp: &str) -> Result<Self, ParseFingerprintError> {
use hex::FromHex;
let hex: Vec<u8> = fp
.as_bytes()
.iter()
.copied()
.filter(|&b| b != b':')
.collect();
let fp = <[u8; 32]>::from_hex(hex).map_err(|_| ParseFingerprintError)?;
Ok(Self::Fingerprint(fp.into()))
}
}
/// A Proxmox API client base backed by a [`proxmox_http::Client`].
pub struct Client {
api_url: Uri,
auth: Mutex<Option<Arc<AuthenticationKind>>>,
client: Arc<proxmox_http::client::Client>,
pve_compat: bool,
cookie_name: Option<String>,
}
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()))
}
pub fn new_with_cookie(api_url: Uri, cookie_name: &str) -> Self {
let mut client =
Client::with_client(api_url, Arc::new(proxmox_http::client::Client::new()));
client.set_cookie_name(cookie_name);
client
}
/// Instantiate a client for an API with a given HTTP client instance.
pub fn with_client(api_url: Uri, client: Arc<proxmox_http::client::Client>) -> Self {
Self {
api_url,
auth: Mutex::new(None),
client,
pve_compat: false,
cookie_name: None,
}
}
/// 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()
}
/// Get a serialized version of the ticket if one is used.
///
/// This returns `None` when using an API token and `Error::Unauthorized` if not logged in.
pub fn serialize_ticket(&self) -> Result<Option<Vec<u8>>, Error> {
let auth = self.authentication().ok_or(Error::Unauthorized)?;
let auth = match &*auth {
AuthenticationKind::Token(_) => return Ok(None),
AuthenticationKind::Ticket(auth) => auth,
};
Ok(Some(serde_json::to_vec(auth).map_err(|err| {
Error::internal("failed to serialize ticket", err)
})?))
}
#[deprecated(note = "use set_authentication instead")]
/// Replace the authentication information with an API token.
pub fn use_api_token(&self, token: Token) {
self.set_authentication(token);
}
/// Replace the currently used authentication.
///
/// This can be a `Token` or an [`Authentication`](proxmox_login::Authentication).
pub fn set_authentication(&self, auth: impl Into<AuthenticationKind>) {
*self.auth.lock().unwrap() = Some(Arc::new(auth.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;
}
pub fn set_cookie_name(&mut self, cookie_name: &str) {
self.cookie_name = Some(cookie_name.to_string());
}
/// 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 send_authenticated_request(
client: Arc<proxmox_http::client::Client>,
auth: Arc<AuthenticationKind>,
method: Method,
uri: Uri,
json_body: Option<String>,
// send an `Accept: application/json-seq` header.
streaming: bool,
) -> Result<(http::response::Parts, hyper::Body), Error> {
let mut request = auth.set_auth_headers(Request::builder().method(method).uri(uri));
if streaming {
request = request.header(http::header::ACCEPT, "application/json-seq");
}
let request = if let Some(body) = json_body {
request
.header(http::header::CONTENT_TYPE, "application/json")
.body(body.into())
} else {
request.body(Default::default())
}
.map_err(|err| Error::internal("failed to build request", err))?;
let response = client
.request(request)
.await
.map_err(|err| Error::Client(err.into()))?;
if response.status() == StatusCode::UNAUTHORIZED {
return Err(Error::Unauthorized);
}
let (response, body) = response.into_parts();
if !response.status.is_success() {
let body = read_body(body).await?;
// 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((response, body))
}
/// Perform an *unauthenticated* HTTP request.
async fn authenticated_request(
client: Arc<proxmox_http::client::Client>,
auth: Arc<AuthenticationKind>,
method: Method,
uri: Uri,
json_body: Option<String>,
) -> Result<HttpApiResponse, Error> {
let (response, body) =
Self::send_authenticated_request(client, auth, method, uri, json_body, false).await?;
let body = read_body(body).await?;
let content_type = match response.headers.get(http::header::CONTENT_TYPE) {
None => None,
Some(value) => Some(
value
.to_str()
.map_err(|err| Error::internal("bad Content-Type header", err))?
.to_owned(),
),
};
Ok(HttpApiResponse {
status: response.status.as_u16(),
content_type,
body,
})
}
/// Assert that we are authenticated and return the `AuthenticationKind`.
/// Otherwise returns `Error::Unauthorized`.
pub fn login_auth(&self) -> Result<Arc<AuthenticationKind>, Error> {
self.auth
.lock()
.unwrap()
.clone()
.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(())
}
async fn do_login_request(
&self,
request: proxmox_login::Request,
) -> Result<(Option<Ticket>, Vec<u8>), Error> {
let request = http::Request::builder()
.method(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))?;
let api_response = self
.client
.request(request)
.await
.map_err(|err| Error::Client(err.into()))?;
if !api_response.status().is_success() {
return Err(Error::api(api_response.status(), "authentication failed"));
}
let (parts, body) = api_response.into_parts();
let body = read_body(body).await?;
let ticket: Option<Ticket> = self.cookie_name.as_ref().and_then(|cookie_name| {
parts
.headers
.get_all(http::header::SET_COOKIE)
.iter()
.filter_map(|c| c.to_str().ok())
.filter_map(|c| match (c.find('='), c.find(';')) {
(Some(begin), Some(end)) if begin < end && &c[..begin] == cookie_name => {
Some(&c[begin + 1..end])
}
_ => None,
})
.filter_map(|t| t.parse().ok())
.next()
});
Ok((ticket, body))
}
/// 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 (ticket, api_response) = self.do_login_request(login.request()).await?;
match login.response_with_cookie_ticket(ticket, &api_response)? {
TicketResult::Full(auth) | TicketResult::HttpOnly(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()),
}
}
/// Attempt to login.
///
/// This will propagate the PVE compatibility state and then perform the `Login` request via
/// the inner http client.
///
/// If the authentication is complete, `None` is returned and the authentication state updated.
/// If a 2nd factor is required, `Some` is returned.
pub async fn login(&self, login: Login) -> Result<Option<SecondFactorChallenge>, Error> {
let login = login.pve_compatibility(self.pve_compat);
let (ticket, api_response) = self.do_login_request(login.request()).await?;
Ok(
match login.response_with_cookie_ticket(ticket, &api_response)? {
TicketResult::TfaRequired(challenge) => Some(challenge),
TicketResult::Full(auth) | TicketResult::HttpOnly(auth) => {
*self.auth.lock().unwrap() = Some(Arc::new(auth.into()));
None
}
},
)
}
/// Attempt to finish a 2nd factor login.
///
/// This will propagate the PVE compatibility state and then perform the `Login` request via
/// the inner http client.
pub async fn login_tfa(
&self,
challenge: SecondFactorChallenge,
challenge_response: proxmox_login::Request,
) -> Result<(), Error> {
let (ticket, api_response) = self.do_login_request(challenge_response).await?;
let auth = challenge.response_with_cookie_ticket(ticket, &api_response)?;
*self.auth.lock().unwrap() = Some(Arc::new(auth.into()));
Ok(())
}
}
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<'a> =
Pin<Box<dyn Future<Output = Result<HttpApiResponse, Error>> + Send + 'a>>;
type ResponseStreamFuture<'a> =
Pin<Box<dyn Future<Output = Result<HttpApiResponseStream<Self::Body>, Error>> + Send + 'a>>;
type Body = hyper::Body;
fn request<'a, T>(
&'a self,
method: Method,
path_and_query: &'a str,
params: Option<T>,
) -> Self::ResponseFuture<'a>
where
T: Serialize + 'a,
{
let params = params
.map(|params| {
serde_json::to_string(&params)
.map_err(|err| Error::internal("failed to serialize parameters", err))
})
.transpose();
Box::pin(async move {
let params = params?;
let auth = self.login_auth()?;
let uri = self.build_uri(path_and_query)?;
let client = Arc::clone(&self.client);
Self::authenticated_request(client, auth, method, uri, params).await
})
}
fn streaming_request<'a, T>(
&'a self,
method: Method,
path_and_query: &'a str,
params: Option<T>,
) -> Self::ResponseStreamFuture<'a>
where
T: Serialize + 'a,
{
let params = params
.map(|params| {
serde_json::to_string(&params)
.map_err(|err| Error::internal("failed to serialize parameters", err))
})
.transpose();
Box::pin(async move {
let params = params?;
let auth = self.login_auth()?;
let uri = self.build_uri(path_and_query)?;
let client = Arc::clone(&self.client);
let (response, body) =
Self::send_authenticated_request(client, auth, method, uri, params, true).await?;
let content_type = match response.headers.get(http::header::CONTENT_TYPE) {
None => None,
Some(value) => Some(
value
.to_str()
.map_err(|err| Error::internal("bad Content-Type header", err))?
.to_owned(),
),
};
Ok(HttpApiResponseStream {
status: response.status.as_u16(),
content_type,
body: Some(body),
})
})
}
}
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 Error {
pub(crate) fn internal<E>(context: &'static str, err: E) -> Self
where
E: StdError + Send + Sync + 'static,
{
Self::Internal(context, Box::new(err))
}
}
impl AuthenticationKind {
pub fn set_auth_headers(&self, request: http::request::Builder) -> http::request::Builder {
match self {
AuthenticationKind::Ticket(auth) => auth.set_auth_headers(request),
AuthenticationKind::Token(auth) => auth.set_auth_headers(request),
}
}
pub fn userid(&self) -> &str {
match self {
AuthenticationKind::Ticket(auth) => &auth.userid,
AuthenticationKind::Token(auth) => &auth.userid,
}
}
}