use crate::libs::config::KaslServerConfig;
use anyhow::{Context, Result, bail};
use reqwest::{Client, StatusCode};
use serde::Deserialize;
use std::fs;
use std::time::Duration;
pub const AGENT_TOKEN_SECRET: &str = ".kasl_server_secret";
pub const AGENT_TOKEN_PROMPT: &str = "Enter the agent token issued by your kasl-server administrator";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, Deserialize)]
pub struct Health {
pub status: String,
pub version: String,
pub database: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AgentIdentity {
pub user_name: String,
pub agent_name: String,
pub api_version: String,
pub server_version: String,
}
#[derive(Debug, Clone)]
pub struct KaslServer {
client: Client,
base_url: String,
}
impl KaslServer {
pub fn new(config: &KaslServerConfig) -> Result<Self> {
let mut builder = Client::builder().timeout(REQUEST_TIMEOUT);
if let Some(path) = &config.ca_certificate {
let pem = fs::read(path).with_context(|| format!("cannot read the CA certificate at '{}'", path))?;
if !looks_like_pem_certificate(&pem) {
bail!(
"'{}' does not contain a PEM-encoded certificate (expected a -----BEGIN CERTIFICATE----- block)",
path
);
}
let certificate = reqwest::Certificate::from_pem(&pem).with_context(|| format!("'{}' is not a PEM-encoded certificate", path))?;
builder = builder.add_root_certificate(certificate);
}
Ok(Self {
client: builder.build().context("cannot build the HTTP client for kasl-server")?,
base_url: normalize_url(&config.url),
})
}
pub async fn health(&self) -> Result<Health> {
let url = format!("{}/health", self.base_url);
let response = self
.client
.get(&url)
.send()
.await
.with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
let status = response.status();
if !status.is_success() {
bail!("{} answered {} instead of a health report", self.base_url, status);
}
response
.json::<Health>()
.await
.with_context(|| format!("{} answered, but not like a kasl-server", self.base_url))
}
pub async fn identify(&self, token: &str) -> Result<AgentIdentity> {
let url = format!("{}/api/v1/agent/whoami", self.base_url);
let response = self
.client
.get(&url)
.bearer_auth(token)
.send()
.await
.with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
match response.status() {
StatusCode::OK => response.json::<AgentIdentity>().await.context("cannot read the server's answer"),
StatusCode::UNAUTHORIZED => bail!("the server rejected this token - it may be mistyped, revoked, or issued for a deactivated account"),
status => bail!("the server answered {} when asked whose token this is", status),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
}
pub fn normalize_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_string()
}
fn looks_like_pem_certificate(pem: &[u8]) -> bool {
pem.windows(BEGIN_CERTIFICATE.len()).any(|window| window == BEGIN_CERTIFICATE)
}
const BEGIN_CERTIFICATE: &[u8] = b"-----BEGIN CERTIFICATE-----";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_url_drops_a_trailing_slash() {
assert_eq!(normalize_url("https://kasl.example.com/"), "https://kasl.example.com");
assert_eq!(normalize_url("https://kasl.example.com"), "https://kasl.example.com");
}
#[test]
fn normalize_url_trims_surrounding_whitespace() {
assert_eq!(normalize_url(" https://kasl.example.com/ "), "https://kasl.example.com");
}
#[test]
fn normalize_url_keeps_a_path_prefix() {
assert_eq!(normalize_url("https://intranet.example.com/kasl/"), "https://intranet.example.com/kasl");
}
#[test]
fn a_client_is_built_without_a_certificate() {
let config = KaslServerConfig {
url: "https://kasl.example.com/".to_string(),
ca_certificate: None,
};
let server = KaslServer::new(&config).unwrap();
assert_eq!(server.base_url(), "https://kasl.example.com");
}
#[test]
fn a_missing_certificate_file_is_reported_by_path() {
let config = KaslServerConfig {
url: "https://kasl.example.com".to_string(),
ca_certificate: Some("/nonexistent/company-ca.pem".to_string()),
};
let error = KaslServer::new(&config).unwrap_err().to_string();
assert!(error.contains("company-ca.pem"), "the error should name the file: {}", error);
}
fn certificate_file(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("kasl-ca-test-{}-{}", std::process::id(), name));
fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{name}.pem"));
fs::write(&path, bytes).unwrap();
(dir, path)
}
#[test]
fn a_certificate_that_is_not_pem_is_refused() {
for (name, bytes) in [
("plain-text", &b"this is not a certificate"[..]),
("empty", &b""[..]),
("wrong-pem-block", &b"-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n"[..]),
("der-as-pem", &[0x30u8, 0x82, 0x01, 0x0a, 0xff, 0xfe][..]),
] {
let (dir, path) = certificate_file(name, bytes);
let config = KaslServerConfig {
url: "https://kasl.example.com".to_string(),
ca_certificate: Some(path.to_string_lossy().into_owned()),
};
let error = match KaslServer::new(&config) {
Ok(_) => panic!("'{name}' should not have been accepted as a certificate"),
Err(error) => error.to_string(),
};
assert!(error.contains("PEM"), "the error for '{}' should say the file is not PEM: {}", name, error);
assert!(error.contains(name), "the error for '{}' should name the file: {}", name, error);
let _ = fs::remove_dir_all(&dir);
}
}
#[test]
fn a_real_certificate_block_is_accepted() {
let (dir, path) = certificate_file("company-ca", b"-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKZ\n-----END CERTIFICATE-----\n");
let config = KaslServerConfig {
url: "https://kasl.example.com".to_string(),
ca_certificate: Some(path.to_string_lossy().into_owned()),
};
let _ = KaslServer::new(&config);
let _ = fs::remove_dir_all(&dir);
}
}