veltrix 0.4.0

General utilities
Documentation
use std::path::Path;

use serde::Serialize;
use tokio::{
    io::{AsyncReadExt, AsyncWriteExt},
    net::UnixStream,
};

use crate::error::{Result, VeltrixError};

use super::{
    spec::{
        CaddyAdminEndpoint, CaddyAdminSpec, CaddyBackendUsed, CaddyEmptyResponse, CaddyResponse,
    },
    types::{CaddyConfig, CaddyIdList, CaddyPkiCaInfo},
};

#[derive(Debug, Clone)]
pub struct CaddyAdminClient {
    spec: CaddyAdminSpec,
}

impl CaddyAdminClient {
    pub fn new(spec: CaddyAdminSpec) -> Self {
        Self { spec }
    }

    pub fn localhost_default() -> Self {
        Self::new(CaddyAdminSpec::localhost_default())
    }

    pub fn spec(&self) -> &CaddyAdminSpec {
        &self.spec
    }

    pub async fn config(&self) -> Result<CaddyResponse<CaddyConfig>> {
        self.request_json("GET", "/config/", Option::<&()>::None)
            .await
    }

    pub async fn load_config(&self, config: &CaddyConfig) -> Result<CaddyEmptyResponse> {
        self.request_empty("POST", "/load", Some(config)).await
    }

    pub async fn stop(&self) -> Result<CaddyEmptyResponse> {
        self.request_empty("POST", "/stop", Option::<&()>::None)
            .await
    }

    pub async fn id_list(&self) -> Result<CaddyResponse<CaddyIdList>> {
        self.request_json("GET", "/id/", Option::<&()>::None).await
    }

    pub async fn pki_ca(&self, ca_id: &str) -> Result<CaddyResponse<CaddyPkiCaInfo>> {
        let path = format!("/pki/ca/{ca_id}");
        self.request_json("GET", &path, Option::<&()>::None).await
    }

    async fn request_json<T, B>(
        &self,
        method: &str,
        path: &str,
        body: Option<B>,
    ) -> Result<CaddyResponse<T>>
    where
        T: serde::de::DeserializeOwned,
        B: Serialize,
    {
        let raw = self.request_raw(method, path, body).await?;

        if raw.body.is_empty() {
            return Err(VeltrixError::service(
                "caddy",
                format!("caddy endpoint returned empty body: {path}"),
            ));
        }

        let data = serde_json::from_slice(&raw.body)
            .map_err(|err| VeltrixError::parsing(format!("invalid caddy json: {err}")))?;

        Ok(CaddyResponse {
            backend: self.backend_used(),
            data,
        })
    }

    async fn request_empty<B>(
        &self,
        method: &str,
        path: &str,
        body: Option<B>,
    ) -> Result<CaddyEmptyResponse>
    where
        B: Serialize,
    {
        self.request_raw(method, path, body).await?;

        Ok(CaddyEmptyResponse {
            backend: self.backend_used(),
        })
    }

    async fn request_raw<B>(
        &self,
        method: &str,
        path: &str,
        body: Option<B>,
    ) -> Result<HttpRawResponse>
    where
        B: Serialize,
    {
        match &self.spec.endpoint {
            CaddyAdminEndpoint::Http { base_url } => {
                http_tcp_json(base_url, method, path, body).await
            }
            CaddyAdminEndpoint::UnixSocket { socket_path } => {
                let body_bytes = match body {
                    Some(body) => Some(serde_json::to_vec(&body).map_err(|err| {
                        VeltrixError::parsing(format!("invalid caddy request json: {err}"))
                    })?),
                    None => None,
                };

                http_unix(socket_path, method, path, body_bytes.as_deref()).await
            }
        }
    }

    fn backend_used(&self) -> CaddyBackendUsed {
        match &self.spec.endpoint {
            CaddyAdminEndpoint::Http { base_url } => CaddyBackendUsed::Http {
                base_url: base_url.clone(),
            },
            CaddyAdminEndpoint::UnixSocket { socket_path } => CaddyBackendUsed::UnixSocket {
                socket_path: socket_path.clone(),
            },
        }
    }
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
struct HttpRawResponse {
    status_code: u16,
    body: Vec<u8>,
}

async fn http_tcp_json<B>(
    base_url: &str,
    method: &str,
    path: &str,
    body: Option<B>,
) -> Result<HttpRawResponse>
where
    B: Serialize,
{
    let base_url = base_url.trim_end_matches('/');
    let url = format!("{base_url}{path}");

    let client = reqwest::Client::new();

    let request = match method {
        "GET" => client.get(&url),
        "POST" => client.post(&url),
        "PUT" => client.put(&url),
        "PATCH" => client.patch(&url),
        "DELETE" => client.delete(&url),
        _ => {
            return Err(VeltrixError::validation(
                "method",
                format!("unsupported caddy HTTP method: {method}"),
            ));
        }
    };

    let request = if let Some(body) = body {
        request.json(&body)
    } else {
        request
    };

    let response = request
        .send()
        .await
        .map_err(|err| VeltrixError::service("caddy", format!("HTTP request failed: {err}")))?;

    let status_code = response.status().as_u16();
    let body = response.bytes().await.map_err(|err| {
        VeltrixError::service("caddy", format!("failed to read HTTP response: {err}"))
    })?;

    if !(200..300).contains(&status_code) {
        return Err(VeltrixError::http(
            status_code,
            format!(
                "caddy HTTP response body: {}",
                String::from_utf8_lossy(&body)
            ),
        ));
    }

    Ok(HttpRawResponse {
        status_code,
        body: body.to_vec(),
    })
}

async fn http_unix(
    socket_path: &Path,
    method: &str,
    path: &str,
    body: Option<&[u8]>,
) -> Result<HttpRawResponse> {
    let mut stream = UnixStream::connect(socket_path)
        .await
        .map_err(|err| VeltrixError::socket(format!("caddy connect failed: {err}")))?;

    let body_len = body.map(|b| b.len()).unwrap_or(0);

    let mut request = format!(
        "{method} {path} HTTP/1.1\r\n\
         Host: caddy\r\n\
         Accept: application/json\r\n\
         Content-Type: application/json\r\n\
         Content-Length: {body_len}\r\n\
         Connection: close\r\n\
         \r\n"
    )
    .into_bytes();

    if let Some(body) = body {
        request.extend_from_slice(body);
    }

    stream
        .write_all(&request)
        .await
        .map_err(|err| VeltrixError::socket(format!("caddy write failed: {err}")))?;
    stream
        .shutdown()
        .await
        .map_err(|err| VeltrixError::socket(format!("caddy shutdown failed: {err}")))?;

    let mut response = Vec::new();
    stream
        .read_to_end(&mut response)
        .await
        .map_err(|err| VeltrixError::socket(format!("caddy read failed: {err}")))?;

    parse_http_response(response, "caddy unix socket")
}

fn parse_http_response(response: Vec<u8>, context: &str) -> Result<HttpRawResponse> {
    let Some(header_end) = find_header_end(&response) else {
        return Err(VeltrixError::parsing(format!(
            "invalid HTTP response from {context}"
        )));
    };

    let headers = &response[..header_end];
    let body = response[header_end + 4..].to_vec();

    let status_line_end = headers
        .windows(2)
        .position(|w| w == b"\r\n")
        .unwrap_or(headers.len());

    let status_line = String::from_utf8_lossy(&headers[..status_line_end]);
    let status_code = parse_status_code(&status_line)?;

    if !(200..300).contains(&status_code) {
        return Err(VeltrixError::http(
            status_code,
            format!(
                "{context} request failed: {status_line}; body: {}",
                String::from_utf8_lossy(&body)
            ),
        ));
    }

    Ok(HttpRawResponse { status_code, body })
}

fn parse_status_code(status_line: &str) -> Result<u16> {
    status_line
        .split_whitespace()
        .nth(1)
        .ok_or_else(|| VeltrixError::parsing(format!("invalid HTTP status line: {status_line}")))?
        .parse::<u16>()
        .map_err(|err| VeltrixError::parsing(format!("invalid HTTP status code: {err}")))
}

fn find_header_end(bytes: &[u8]) -> Option<usize> {
    bytes.windows(4).position(|window| window == b"\r\n\r\n")
}