start on creating a client
This commit is contained in:
commit
ad428dfa97
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
||||
1815
Cargo.lock
generated
Normal file
1815
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
Cargo.toml
Normal file
20
Cargo.toml
Normal file
@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "checkmk-api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors = [
|
||||
"Vincent Stuyck <vincent.stuyck@vince-it.com>"
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22.1"
|
||||
bytes = "1.11.0"
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
log = "0.4.29"
|
||||
reqwest = "0.12.26"
|
||||
secrecy = { version = "0.10.3", features = ["serde"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
simplelog = "0.12.2"
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
27
src/api/mod.rs
Normal file
27
src/api/mod.rs
Normal file
@ -0,0 +1,27 @@
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DomainType {
|
||||
Link,
|
||||
HostConfig,
|
||||
FolderConfig,
|
||||
ContactGroupConfig,
|
||||
HostGroupConfig,
|
||||
ServiceGroupConfig,
|
||||
}
|
||||
|
||||
impl fmt::Display for DomainType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DomainType::HostConfig => write!(f, "host_config"),
|
||||
DomainType::FolderConfig => write!(f, "folder_config"),
|
||||
DomainType::Link => write!(f, "link"),
|
||||
DomainType::ContactGroupConfig => write!(f, "contact_group_config"),
|
||||
DomainType::HostGroupConfig => write!(f, "host_group_config"),
|
||||
DomainType::ServiceGroupConfig => write!(f, "service_group_config"),
|
||||
}
|
||||
}
|
||||
}
|
||||
256
src/client.rs
Normal file
256
src/client.rs
Normal file
@ -0,0 +1,256 @@
|
||||
use std::fmt::Display;
|
||||
use std::marker::PhantomData;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::trace;
|
||||
use reqwest::{Certificate, Request};
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::api::DomainType;
|
||||
use crate::{Error, Result};
|
||||
|
||||
|
||||
pub struct Client(Arc<InnerClient>);
|
||||
struct InnerClient {
|
||||
http: reqwest::Client,
|
||||
url: String,
|
||||
semaphore: Semaphore
|
||||
}
|
||||
|
||||
#[derive(Default, PartialEq, Eq)]
|
||||
enum SslStrategy {
|
||||
NoSll,
|
||||
IngoreHostname,
|
||||
IgnoreCertificate,
|
||||
#[default] Strict
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RequiresLocation;
|
||||
#[derive(Default)]
|
||||
pub struct RequiresCredentials;
|
||||
#[derive(Default)]
|
||||
pub struct RequiresNothing;
|
||||
#[derive(Default)]
|
||||
pub struct ClientBuilder<T> {
|
||||
hostname: String,
|
||||
sitename: String,
|
||||
username: String,
|
||||
password: String,
|
||||
ssl: SslStrategy,
|
||||
root_certificate: Option<Certificate>,
|
||||
resolve: Option<SocketAddr>,
|
||||
port: u16,
|
||||
|
||||
_marker: PhantomData<T>
|
||||
}
|
||||
|
||||
impl <T> ClientBuilder<T> {
|
||||
fn new() -> ClientBuilder<RequiresLocation> {
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientBuilder<RequiresLocation> {
|
||||
pub fn on_host_and_site(self, hostname: String, sitename: String) -> ClientBuilder<RequiresCredentials> {
|
||||
ClientBuilder::<RequiresCredentials> {
|
||||
hostname, sitename,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientBuilder<RequiresCredentials> {
|
||||
pub fn with_credentials(self, username: String, password: String) -> ClientBuilder<RequiresNothing> {
|
||||
ClientBuilder {
|
||||
hostname: self.hostname,
|
||||
sitename: self.sitename,
|
||||
username, password,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ClientBuildResult<T> = std::result::Result<T, ClientBuildError>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ClientBuildError {
|
||||
#[error("failed to build client: {0}")]
|
||||
BuildClient(#[from] reqwest::Error),
|
||||
#[error("invalid credentials. thise can only consist of visibly ASCII characters (ie. (32-127))")]
|
||||
InvalidCredentials(#[from] reqwest::header::InvalidHeaderValue)
|
||||
}
|
||||
|
||||
impl ClientBuilder<RequiresNothing> {
|
||||
pub fn with_specific_port(mut self, port: u16) -> Self {
|
||||
self.port = port;
|
||||
self
|
||||
}
|
||||
pub fn without_ssl(mut self) -> Self {
|
||||
self.ssl = SslStrategy::NoSll;
|
||||
self
|
||||
}
|
||||
pub fn ignore_hostname_verification(mut self) -> Self {
|
||||
self.ssl = SslStrategy::IngoreHostname;
|
||||
self
|
||||
}
|
||||
pub fn ignore_certificate_veritifcation(mut self) -> Self {
|
||||
self.ssl = SslStrategy::IgnoreCertificate;
|
||||
self
|
||||
}
|
||||
pub fn with_root_certificate(mut self, certificate: Certificate) -> Self {
|
||||
let _ = self.root_certificate.insert(certificate);
|
||||
self
|
||||
}
|
||||
pub fn resolve(mut self, ipaddr: IpAddr) -> Self {
|
||||
let _ = self.resolve.insert(SocketAddr::new(ipaddr, 0));
|
||||
self
|
||||
}
|
||||
|
||||
fn protocol(&self) -> &str {
|
||||
if self.ssl == SslStrategy::NoSll {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
}
|
||||
}
|
||||
fn basic_auth_header(&self) -> ClientBuildResult<(HeaderName, HeaderValue)> {
|
||||
let header = format!("Bearer {} {}", self.username, self.password);
|
||||
let mut header = HeaderValue::from_str(&header)?;
|
||||
header.set_sensitive(true);
|
||||
Ok((reqwest::header::AUTHORIZATION, header))
|
||||
}
|
||||
pub fn build(self) -> Result<Client> {
|
||||
const API_BASE: &str = "check_mk/api/1.0";
|
||||
let url = format!(
|
||||
"{}://{}:{}/{}/{API_BASE}",
|
||||
self.protocol(),
|
||||
self.hostname,
|
||||
self.port,
|
||||
self.sitename,
|
||||
);
|
||||
trace!("checkmk url: {url}");
|
||||
|
||||
let http = {
|
||||
let headers = [self.basic_auth_header()?].into_iter().collect();
|
||||
let mut builder = reqwest::ClientBuilder::new()
|
||||
.default_headers(headers)
|
||||
.danger_accept_invalid_certs(self.ssl == SslStrategy::IgnoreCertificate)
|
||||
.danger_accept_invalid_hostnames(self.ssl == SslStrategy::IngoreHostname);
|
||||
|
||||
if let Some(sock) = self.resolve {
|
||||
builder = builder.resolve(&self.hostname, sock);
|
||||
}
|
||||
if let Some(certificate) = self.root_certificate {
|
||||
builder = builder.add_root_certificate(certificate);
|
||||
}
|
||||
|
||||
builder.build()
|
||||
.map_err(ClientBuildError::BuildClient)
|
||||
.map_err(Error::BuildClient)?
|
||||
};
|
||||
|
||||
// checkmk api struggles with more than 10 requests at a time
|
||||
// not sure if this has been inproved since 2.2
|
||||
let semaphore = Semaphore::new(10);
|
||||
|
||||
Ok(Client(Arc::new(InnerClient { http, url, semaphore })))
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn from_parts(url: String, client: reqwest::Client) -> Self {
|
||||
Self(Arc::new(InnerClient {
|
||||
http: client,
|
||||
semaphore: Semaphore::new(10),
|
||||
url
|
||||
}))
|
||||
}
|
||||
pub fn builder() -> ClientBuilder<RequiresLocation> {
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl InnerClient {
|
||||
pub(crate) fn object_url(&self, domain_type: DomainType, id: impl Display) -> String {
|
||||
format!("{}/objects/{}/{}", self.url, domain_type, id)
|
||||
}
|
||||
pub(crate) fn collection_url(&self, domain_type: DomainType) -> String {
|
||||
format!("{}/domain-types/{}/collections/all", self.url, domain_type)
|
||||
}
|
||||
pub(crate) fn bulk_action_url(&self, domain_type: DomainType, action: BulkAction) -> String {
|
||||
format!("{}/domain-types/{}/actions/bulk-{}/invoke",self.url, domain_type, action)
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_request(&self, request: Request) -> Result<String> {
|
||||
trace!("sending {}-request to {}", request.method(), request.url());
|
||||
let response = self.http
|
||||
.execute(request)
|
||||
.await
|
||||
.map_err(Error::SendRequest)?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text()
|
||||
.await
|
||||
.map_err(Error::ReceiveBody)?;
|
||||
|
||||
if status.is_success() {
|
||||
Ok(body)
|
||||
} else {
|
||||
let cmkerror = serde_json::from_str(&body)
|
||||
.map_err(Error::DeserializeResponse)?;
|
||||
Err(Error::CheckmkError(cmkerror))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn invoke_api(&self, request: Request) -> Result<()> {
|
||||
let _response = self.handle_request(request).await?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) async fn query_api<T: DeserializeOwned>(&self, request: Request) -> Result<T> {
|
||||
let response = self.handle_request(request).await?;
|
||||
serde_json::from_str(&response).map_err(Error::DeserializeResponse)
|
||||
}
|
||||
|
||||
pub async fn get_etag(&self, domain_type: DomainType, id: &str) -> Result<HeaderValue> {
|
||||
let mut response = self.http
|
||||
.head(self.object_url(domain_type, id))
|
||||
.send()
|
||||
.await
|
||||
.map_err(Error::SendRequest)?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response.headers_mut()
|
||||
.remove("ETag")
|
||||
.ok_or(Error::MissingHeader("ETag"))
|
||||
} else {
|
||||
let body = response.text()
|
||||
.await
|
||||
.map_err(Error::ReceiveBody)?;
|
||||
let cmkerror = serde_json::from_str(&body)
|
||||
.map_err(Error::DeserializeResponse)?;
|
||||
Err(Error::CheckmkError(cmkerror))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum BulkAction {
|
||||
Create,
|
||||
// Read,
|
||||
Update,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl Display for BulkAction {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
BulkAction::Create => write!(f, "create"),
|
||||
// BulkAction::Read => write!(f, "read"),
|
||||
BulkAction::Update => write!(f, "update"),
|
||||
BulkAction::Delete => write!(f, "delete"),
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/error.rs
Normal file
53
src/error.rs
Normal file
@ -0,0 +1,53 @@
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error as _;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::client::ClientBuildError;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("failed to build client: {0}")]
|
||||
BuildClient(#[from] ClientBuildError),
|
||||
|
||||
#[error(
|
||||
"Failed to send request: {}: {}", .0,
|
||||
.0.cause().map(|e| e.to_string())
|
||||
.unwrap_or_else(|| "Uknown Cause".to_string())
|
||||
)]
|
||||
SendRequest(#[source] reqwest::Error),
|
||||
#[error("Failed to receive response-body: {0}")]
|
||||
ReceiveBody(#[source] reqwest::Error),
|
||||
#[error("Failed to deserialize response: {0}")]
|
||||
DeserializeResponse(#[source] serde_json::Error),
|
||||
#[error("Recieved an error from checkmk ({}): {}", .0.status, .0.detail)]
|
||||
CheckmkError(#[source] CheckmkError),
|
||||
|
||||
#[error("Missing header: {0}")]
|
||||
MissingHeader(&'static str),
|
||||
#[error("Failed to parse ETag header: {0}")]
|
||||
ParseEtag(#[source] std::string::FromUtf8Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckmkError {
|
||||
pub title: String,
|
||||
pub status: u16,
|
||||
pub detail: String,
|
||||
#[serde(default)]
|
||||
pub fields: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl std::error::Error for CheckmkError {}
|
||||
|
||||
impl std::fmt::Display for CheckmkError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"CheckmkError {} ({}): {}",
|
||||
self.status, self.title, self.detail
|
||||
)
|
||||
}
|
||||
}
|
||||
5
src/lib.rs
Normal file
5
src/lib.rs
Normal file
@ -0,0 +1,5 @@
|
||||
mod client;
|
||||
mod error;
|
||||
pub mod api;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
Loading…
x
Reference in New Issue
Block a user