diff --git a/Cargo.lock b/Cargo.lock index 22d5dcf..7f829bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -625,6 +625,7 @@ version = "0.1.0" dependencies = [ "base64 0.21.0", "base64-serde", + "chrono", "log", "rand", "reqwest", diff --git a/dnapi-rs/Cargo.toml b/dnapi-rs/Cargo.toml index 87b08d6..682e4e6 100644 --- a/dnapi-rs/Cargo.toml +++ b/dnapi-rs/Cargo.toml @@ -20,4 +20,10 @@ url = "2.3.1" base64 = "0.21.0" serde_json = "1.0.95" trifid-pki = { version = "0.1.6", path = "../trifid-pki" } -rand = "0.8.5" \ No newline at end of file +rand = "0.8.5" +chrono = "0.4.24" + +[features] +default = ["blocking"] +blocking = ["reqwest/blocking"] +async = [] \ No newline at end of file diff --git a/dnapi-rs/src/client.rs b/dnapi-rs/src/client.rs deleted file mode 100644 index 4dc1f83..0000000 --- a/dnapi-rs/src/client.rs +++ /dev/null @@ -1 +0,0 @@ -//! Client structs to handle communication with the Defined Networking API. \ No newline at end of file diff --git a/dnapi-rs/src/client_async.rs b/dnapi-rs/src/client_async.rs new file mode 100644 index 0000000..e69de29 diff --git a/dnapi-rs/src/client_blocking.rs b/dnapi-rs/src/client_blocking.rs new file mode 100644 index 0000000..780cc32 --- /dev/null +++ b/dnapi-rs/src/client_blocking.rs @@ -0,0 +1,102 @@ +//! Client structs to handle communication with the Defined Networking API. This is the blocking client API - if you want async instead, set no-default-features and enable the async feature instead. + +use std::error::Error; +use chrono::Local; +use log::{debug, error}; +use url::Url; +use trifid_pki::cert::serialize_ed25519_public; +use crate::credentials::{Credentials, ed25519_public_keys_from_pem}; +use crate::crypto::new_keys; +use crate::message::{ENROLL_ENDPOINT, EnrollRequest, EnrollResponse}; + +/// A type alias to abstract return types +pub type NebulaConfig = Vec; + +/// A type alias to abstract DH private keys +pub type DHPrivateKeyPEM = Vec; + +/// A combination of persistent data and HTTP client used for communicating with the API. +pub struct Client { + http_client: reqwest::blocking::Client, + server_url: Url +} + +/// A struct containing organization metadata returned as a result of enrollment +pub struct EnrollMeta { + /// The server organization ID this node is now a member of + pub organization_id: String, + /// The server organization name this node is now a member of + pub organization_name: String +} + +impl Client { + /// Create a new `Client` configured with the given User-Agent and API base. + /// # Errors + /// This function will return an error if the reqwest Client could not be created. + pub fn new(user_agent: String, api_base: Url) -> Result> { + let client = reqwest::blocking::Client::builder().user_agent(user_agent).build()?; + Ok(Self { + http_client: client, + server_url: api_base + }) + } + + /// Issues an enrollment request against the REST API using the given enrollment code, passing along a + /// locally generated DH X25519 Nebula key to be signed by the CA, and an Ed25519 key for future API + /// authentication. On success it returns the Nebula config generated by the server, a Nebula private key PEM, + /// credentials to be used for future DN API requests, and an object containing organization information. + /// # Errors + /// This function will return an error in any of the following situations: + /// - the server_url is invalid + /// - the HTTP request fails + /// - the HTTP response is missing X-Request-ID + /// - X-Request-ID isn't valid UTF-8 + /// - the server returns an error + /// - the server returns invalid JSON + /// - the `trusted_keys` field is invalid + pub fn enroll(&self, code: &str) -> Result<(NebulaConfig, DHPrivateKeyPEM, Credentials, EnrollMeta), Box> { + debug!("making enrollment request to API {{server: {}, code: {}}}", self.server_url, code); + + let (dh_pubkey_pem, dh_privkey_pem, ed_pubkey, ed_privkey) = new_keys(); + + let req_json = serde_json::to_string(&EnrollRequest { + code: code.to_string(), + dh_pubkey: dh_pubkey_pem, + ed_pubkey: serialize_ed25519_public(ed_pubkey.as_bytes()), + timestamp: Local::now().format("%Y-%m-%dT%H:%M:%S.%f%:z").to_string(), + })?; + + let resp = self.http_client.post(self.server_url.join(ENROLL_ENDPOINT)?).body(req_json).send()?; + + let req_id = resp.headers().get("X-Request-ID").ok_or("Response missing X-Request-ID")?.to_str()?; + debug!("enrollment request complete {{req_id: {}}}", req_id); + + let resp: EnrollResponse = resp.json()?; + + let r = match resp { + EnrollResponse::Success { data } => data, + EnrollResponse::Error { errors } => { + error!("unexpected error during enrollment: {}", errors[0].message); + return Err(errors[0].message.clone().into()); + } + }; + + let meta = EnrollMeta { + organization_id: r.organization.id, + organization_name: r.organization.name, + }; + + let trusted_keys = ed25519_public_keys_from_pem(&r.trusted_keys)?; + + let creds = Credentials { + host_id: r.host_id, + ed_privkey, + counter: r.counter, + trusted_keys, + }; + + Ok((r.config, dh_privkey_pem, creds, meta)) + } + + +} \ No newline at end of file diff --git a/dnapi-rs/src/lib.rs b/dnapi-rs/src/lib.rs index 8603e30..1317543 100644 --- a/dnapi-rs/src/lib.rs +++ b/dnapi-rs/src/lib.rs @@ -15,7 +15,18 @@ #![allow(clippy::too_many_lines)] #![allow(clippy::module_name_repetitions)] +#[cfg(all(feature = "blocking", feature = "async"))] +compile_error!("Cannot compile with both blocking and async features at the same time. Please pick one or the other."); + pub mod message; + +#[cfg(feature = "blocking")] +#[path = "client_blocking.rs"] pub mod client; + +#[cfg(feature = "async")] +#[path = "client_async.rs"] +pub mod client; + pub mod credentials; pub mod crypto; \ No newline at end of file diff --git a/dnapi-rs/src/message.rs b/dnapi-rs/src/message.rs index e52140e..31f4a29 100644 --- a/dnapi-rs/src/message.rs +++ b/dnapi-rs/src/message.rs @@ -4,7 +4,7 @@ use base64_serde::base64_serde_type; use serde::{Serialize, Deserialize}; /// The version 1 `DNClient` API endpoint -const ENDPOINT_V1: &str = "/v1/dnclient"; +pub const ENDPOINT_V1: &str = "/v1/dnclient"; base64_serde_type!(Base64Standard, base64::engine::general_purpose::STANDARD); @@ -114,7 +114,7 @@ pub struct DoUpdateResponse { } /// The REST enrollment endpoint -const ENROLL_ENDPOINT: &str = "/v2/enroll"; +pub const ENROLL_ENDPOINT: &str = "/v2/enroll"; #[derive(Serialize, Deserialize)] /// `EnrollRequest` is issued to the `ENROLL_ENDPOINT` to enroll this `dnclient` with a dnapi organization