From 34bab6c8c72433dfb51b7bd75c7f38e127f31596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Lemaire-Giroud?= Date: Mon, 5 May 2025 18:51:13 +0200 Subject: [PATCH] feat: introduce network-group API --- Cargo.toml | 19 +- src/lib.rs | 83 ++++---- src/v2/error.rs | 25 +++ src/v2/mod.rs | 1 + src/v4/functions/deployments.rs | 2 +- src/v4/http_error.rs | 103 ++++++++++ src/v4/mod.rs | 3 + src/v4/network_group/delete.rs | 32 +++ src/v4/network_group/mod.rs | 15 ++ src/v4/network_group/network_group_id.rs | 80 ++++++++ src/v4/network_group/peer.rs | 126 ++++++++++++ src/v4/network_group/wannabe_external_peer.rs | 94 +++++++++ src/v4/network_group/wireguard.rs | 194 ++++++++++++++++++ .../network_group/wireguard_configuration.rs | 144 +++++++++++++ 14 files changed, 873 insertions(+), 48 deletions(-) create mode 100644 src/v2/error.rs create mode 100644 src/v4/http_error.rs create mode 100644 src/v4/network_group/delete.rs create mode 100644 src/v4/network_group/mod.rs create mode 100644 src/v4/network_group/network_group_id.rs create mode 100644 src/v4/network_group/peer.rs create mode 100644 src/v4/network_group/wannabe_external_peer.rs create mode 100644 src/v4/network_group/wireguard.rs create mode 100644 src/v4/network_group/wireguard_configuration.rs diff --git a/Cargo.toml b/Cargo.toml index c2777fc..6739b38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,11 +13,14 @@ keywords = ["clevercloud", "sdk", "logging", "metrics", "jsonschemas"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -chrono = { version = "^0.4.40", features = ["serde"] } -oauth10a = { version = "^2.1.1", default-features = false, features = [ +base64 = { version = "^0.22.1", optional = true } +chrono = { version = "^0.4.41", features = ["serde"] } +oauth10a = { path = "../oauth10a-rust", default-features = false, features = [ "client", + "sse", ] } -log = { version = "^0.4.26", optional = true } +log = { version = "^0.4.27", optional = true } +rand_core = { version = "^0.9.3", features = ["os_rng"], optional = true } schemars = { version = "^0.8.22", features = [ "chrono", "indexmap1", @@ -30,11 +33,17 @@ serde_repr = "^0.1.20" serde_json = "^1.0.140" thiserror = "^2.0.12" tracing = { version = "^0.1.41", optional = true } -uuid = { version = "^1.16.0", features = ["serde", "v4"] } +uuid = { version = "^1.17.0", features = ["serde", "v4"] } +x25519-dalek = { version = "^2.0.1", features = [ + "zeroize", + "static_secrets", +], optional = true } +zeroize = { version = "^1.8.1", optional = true } [features] -default = ["logging"] +default = ["logging", "network-group"] jsonschemas = ["schemars"] logging = ["oauth10a/logging", "tracing/log-always", "log"] metrics = ["oauth10a/metrics"] tracing = ["oauth10a/tracing", "dep:tracing"] +network-group = ["dep:base64", "x25519-dalek", "zeroize", "rand_core"] diff --git a/src/lib.rs b/src/lib.rs index 8a60552..8d7ac66 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,14 @@ //! # Clever-Cloud Sdk //! -//! This module provides a client and structures to interact with clever-cloud -//! api. +//! This module provides a client and structures to interact with Clever Cloud API. -use std::fmt::Debug; +use core::fmt; +use ::oauth10a::client::{Execute, reqwest::IntoUrl}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use crate::oauth10a::{ - Client as OAuthClient, ClientError, Request, RestClient, + Client as OAuthClient, ClientError, RestClient, reqwest::{self, Method}, }; @@ -46,7 +46,7 @@ pub fn default_consumer_secret() -> String { // ----------------------------------------------------------------------------- // Credentials structure -#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)] +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone)] #[serde(untagged)] pub enum Credentials { OAuth1 { @@ -71,6 +71,16 @@ pub enum Credentials { }, } +impl fmt::Debug for Credentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OAuth1 { .. } => f.write_str("OAuth1"), + Self::Basic { .. } => f.write_str("Basic"), + Self::Bearer { .. } => f.write_str("Bearer"), + } + } +} + impl Default for Credentials { #[tracing::instrument(skip_all)] fn default() -> Self { @@ -208,80 +218,69 @@ pub struct Client { endpoint: String, } -impl Request for Client { +impl Execute for Client { type Error = ClientError; #[cfg_attr(feature = "tracing", tracing::instrument)] + fn execute( + &self, + request: reqwest::Request, + ) -> impl Future> + Send + 'static { + self.inner.execute(request) + } +} + +impl RestClient for Client { fn request( &self, method: &Method, - endpoint: &str, + endpoint: X, payload: &T, - ) -> impl Future> + ) -> impl Future> + Send where - T: Serialize + Debug + Send + Sync, - U: DeserializeOwned + Debug + Send + Sync, + T: ?Sized + Serialize + fmt::Debug + Send + Sync, + U: DeserializeOwned + fmt::Debug + Send + Sync, { self.inner.request(method, endpoint, payload) } #[cfg_attr(feature = "tracing", tracing::instrument)] - fn execute( - &self, - request: reqwest::Request, - ) -> impl Future> { - self.inner.execute(request) - } -} - -impl RestClient for Client { - type Error = ClientError; - - #[cfg_attr(feature = "tracing", tracing::instrument)] - fn get(&self, endpoint: &str) -> impl Future> + fn get(&self, endpoint: X) -> impl Future> where - T: DeserializeOwned + Debug + Send + Sync, + T: DeserializeOwned + fmt::Debug + Send + Sync, { self.inner.get(endpoint) } #[cfg_attr(feature = "tracing", tracing::instrument)] - fn post( - &self, - endpoint: &str, - payload: &T, - ) -> impl Future> + fn post(&self, endpoint: X, payload: &T) -> impl Future> where - T: Serialize + Debug + Send + Sync, - U: DeserializeOwned + Debug + Send + Sync, + T: Serialize + fmt::Debug + Send + Sync + ?Sized, + U: DeserializeOwned + fmt::Debug + Send + Sync, { self.inner.post(endpoint, payload) } #[cfg_attr(feature = "tracing", tracing::instrument)] - fn put(&self, endpoint: &str, payload: &T) -> impl Future> + fn put(&self, endpoint: X, payload: &T) -> impl Future> where - T: Serialize + Debug + Send + Sync, - U: DeserializeOwned + Debug + Send + Sync, + T: Serialize + fmt::Debug + Send + Sync + ?Sized, + U: DeserializeOwned + fmt::Debug + Send + Sync, { self.inner.put(endpoint, payload) } #[cfg_attr(feature = "tracing", tracing::instrument)] - fn patch( - &self, - endpoint: &str, - payload: &T, - ) -> impl Future> + fn patch(&self, endpoint: X, payload: &T) -> impl Future> where - T: Serialize + Debug + Send + Sync, - U: DeserializeOwned + Debug + Send + Sync, + T: Serialize + fmt::Debug + Send + Sync + ?Sized, + U: DeserializeOwned + fmt::Debug + Send + Sync, { self.inner.patch(endpoint, payload) } #[cfg_attr(feature = "tracing", tracing::instrument)] - fn delete(&self, endpoint: &str) -> impl Future> { + fn delete(&self, endpoint: X) -> impl Future> { self.inner.delete(endpoint) } } diff --git a/src/v2/error.rs b/src/v2/error.rs new file mode 100644 index 0000000..aa87022 --- /dev/null +++ b/src/v2/error.rs @@ -0,0 +1,25 @@ +use core::{error::Error, fmt}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseError { + #[serde(rename = "id")] + pub id: u32, + #[serde(rename = "message")] + pub message: String, + #[serde(rename = "type")] + pub kind: String, +} + +impl fmt::Display for ResponseError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "got response {} ({}), {}", + self.kind, self.id, self.message + ) + } +} + +impl Error for ResponseError {} diff --git a/src/v2/mod.rs b/src/v2/mod.rs index 74462e6..1ec94d7 100644 --- a/src/v2/mod.rs +++ b/src/v2/mod.rs @@ -3,5 +3,6 @@ //! This module expose resources under the version 2 of the Clever-Cloud Api. pub mod addon; +pub mod error; pub mod myself; pub mod plan; diff --git a/src/v4/functions/deployments.rs b/src/v4/functions/deployments.rs index 173fe02..f2610dd 100644 --- a/src/v4/functions/deployments.rs +++ b/src/v4/functions/deployments.rs @@ -10,7 +10,7 @@ use std::{ use chrono::{DateTime, Utc}; use log::{Level, debug, log_enabled}; use oauth10a::client::{ - ClientError, Request, RestClient, + ClientError, Execute, RestClient, reqwest::{ self, Body, Method, header::{CONTENT_LENGTH, CONTENT_TYPE, HeaderValue}, diff --git a/src/v4/http_error.rs b/src/v4/http_error.rs new file mode 100644 index 0000000..492ba73 --- /dev/null +++ b/src/v4/http_error.rs @@ -0,0 +1,103 @@ +use oauth10a::client::reqwest::StatusCode; +use serde::{Deserialize, Serialize}; + +pub type HttpOutput = (StatusCode, HttpError); + +mod http_error_context { + use std::collections::HashMap; + + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Deserialize, Serialize)] + pub struct Empty; + + #[derive(Debug, Deserialize, Serialize)] + pub struct FieldError { + #[serde(rename = "value")] + value: String, + #[serde(rename = "reason")] + reason: Option, + } + + #[derive(Debug, Deserialize, Serialize)] + pub struct Field { + #[serde(rename = "name")] + name: String, + #[serde(rename = "error")] + error: FieldError, + } + + #[derive(Debug, Deserialize, Serialize)] + pub struct GatewayError { + #[serde(rename = "originalStatus")] + original_status: i32, + #[serde(rename = "originalBody")] + original_body: String, + } + + #[derive(Debug, Deserialize, Serialize)] + pub struct Input { + #[serde(rename = "names")] + names: Vec, + } + + type MapFieldError = HashMap; + + #[derive(Debug, Deserialize, Serialize)] + pub struct MultipleFields { + #[serde(rename = "fields")] + fields: MapFieldError, + } + + #[derive(Debug, Deserialize, Serialize)] + pub struct Resource { + #[serde(rename = "kind")] + kind: String, + #[serde(rename = "name")] + name: String, + } + + #[derive(Debug, Deserialize, Serialize)] + pub struct Operation { + #[serde(rename = "operation")] + operation: String, + #[serde(rename = "kind")] + kind: String, + #[serde(rename = "name")] + name: Option, + } + + #[derive(Debug, Deserialize, Serialize)] + pub struct Selector { + #[serde(rename = "path")] + path: Vec, + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum HttpErrorContext { + Empty(http_error_context::Empty), + Field(http_error_context::Field), + Gateway(http_error_context::GatewayError), + Input(http_error_context::Input), + MultipleFields(http_error_context::MultipleFields), + Operation(http_error_context::Operation), + Resource(http_error_context::Resource), + Selector(http_error_context::Selector), +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct HttpError { + #[serde(rename = "apiRequestId")] + pub api_request_id: String, + #[serde(rename = "code")] + pub code: String, + #[serde(rename = "context")] + pub context: HttpErrorContext, + #[serde(rename = "error")] + pub error: String, +} + +// TODO: unit tests +// maybe use https://github.com/Orange-OpenSource/hurl or something diff --git a/src/v4/mod.rs b/src/v4/mod.rs index e8e2a62..b5690d9 100644 --- a/src/v4/mod.rs +++ b/src/v4/mod.rs @@ -4,4 +4,7 @@ pub mod addon_provider; pub mod functions; +pub mod http_error; +#[cfg(feature = "network-group")] +pub mod network_group; pub mod products; diff --git a/src/v4/network_group/delete.rs b/src/v4/network_group/delete.rs new file mode 100644 index 0000000..44bed1b --- /dev/null +++ b/src/v4/network_group/delete.rs @@ -0,0 +1,32 @@ +use oauth10a::client::{ClientError, RestClient}; +use tracing::debug; + +use crate::Client; + +use super::{OwnerId, network_group_id::NetworkGroupId, peer::PeerId}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("failed to delete for owner '{0}', network group '{1}', peer id '{2}', {0}")] + Delete(String, NetworkGroupId, PeerId, ClientError), +} + +pub async fn delete( + client: &Client, + owner_id: &OwnerId, + ng_id: &NetworkGroupId, + peer_id: PeerId, +) -> Result<(), Error> { + let endpoint = format!( + "{}/v4/networkgroups/organisations/{owner_id}/networkgroups/{ng_id}/external-peers/{peer_id}", + client.endpoint, + ); + + #[cfg(feature = "tracing")] + debug!("execute a request to delete peer from Network Group, endpoint: '{endpoint}'"); + + client + .delete(&endpoint) + .await + .map_err(|e| Error::Delete(owner_id.to_owned(), *ng_id, peer_id, e)) +} diff --git a/src/v4/network_group/mod.rs b/src/v4/network_group/mod.rs new file mode 100644 index 0000000..9eda982 --- /dev/null +++ b/src/v4/network_group/mod.rs @@ -0,0 +1,15 @@ +//! # Network Group module +//! +//! This module provide structures and helpers to interact with Clever Cloud's +//! Network Group API. + +pub type MemberId = String; + +pub type OwnerId = String; + +pub mod delete; +pub mod network_group_id; +pub mod peer; +pub mod wannabe_external_peer; +pub mod wireguard; +pub mod wireguard_configuration; diff --git a/src/v4/network_group/network_group_id.rs b/src/v4/network_group/network_group_id.rs new file mode 100644 index 0000000..ecc7a74 --- /dev/null +++ b/src/v4/network_group/network_group_id.rs @@ -0,0 +1,80 @@ +use core::{fmt, str}; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// NETWORK GROUP ID PARSE ERROR //////////////////////////////////////////////// + +#[derive(Debug, thiserror::Error)] +enum NetworkGroupIdParseErrorKind { + #[error("expected `ng_` prefix")] + MissingPrefix, + #[error("invalid UUID, {0}")] + Uuid(uuid::Error), +} + +#[derive(Debug, thiserror::Error)] +#[error("invalid network group identifier: {kind}")] +pub struct NetworkGroupIdParseError { + kind: NetworkGroupIdParseErrorKind, +} + +// NETWORK GROUP ID //////////////////////////////////////////////////////////// + +/// A network group identifier. +/// +/// It is represented as an hyphenate lowercased UUID prefixed with `ng_`. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct NetworkGroupId(Uuid); + +impl NetworkGroupId { + /// Returns the name of the file in which the peer identifier is stored. + pub fn ng_info_file_name(&self) -> PathBuf { + PathBuf::from(self.to_string()).with_extension("id") + } + + /// Returns the associated Wireguard interface name. + pub fn wg_interface_name(&self) -> String { + let time_low = &self.0.to_string()[..8]; + format!("wgcc{time_low}") + } +} + +impl str::FromStr for NetworkGroupId { + type Err = NetworkGroupIdParseError; + + fn from_str(s: &str) -> Result { + match s.strip_prefix("ng_") { + Some(s) => match s.parse::() { + Ok(hyphenated) => Ok(Self(hyphenated.into_uuid())), + Err(e) => Err(NetworkGroupIdParseError { + kind: NetworkGroupIdParseErrorKind::Uuid(e), + }), + }, + None => Err(NetworkGroupIdParseError { + kind: NetworkGroupIdParseErrorKind::MissingPrefix, + }), + } + } +} + +impl fmt::Display for NetworkGroupId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ng_{}", self.0.as_hyphenated()) + } +} + +impl<'de> Deserialize<'de> for NetworkGroupId { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer)? + .parse() + .map_err(serde::de::Error::custom) + } +} + +impl Serialize for NetworkGroupId { + fn serialize(&self, serializer: S) -> Result { + self.to_string().serialize(serializer) + } +} diff --git a/src/v4/network_group/peer.rs b/src/v4/network_group/peer.rs new file mode 100644 index 0000000..c9d690d --- /dev/null +++ b/src/v4/network_group/peer.rs @@ -0,0 +1,126 @@ +use core::{fmt, str}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// PEER KIND /////////////////////////////////////////////////////////////////// + +#[derive( + Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize, +)] +pub enum PeerKind { + #[default] + #[serde(rename = "CLEVER")] + Clever, + #[serde(rename = "EXTERNAL")] + External, +} + +impl PeerKind { + /// Returns `true` if the peer kind is [`External`](PeerKind::External). + pub const fn is_external(&self) -> bool { + matches!(self, Self::External) + } + + const fn prefix(self) -> Option<&'static str> { + match self { + Self::Clever => None, + Self::External => Some("external"), + } + } + + const fn from_prefix(prefix: &str) -> Option { + match prefix.as_bytes() { + b"external" => Some(Self::External), + _ => None, + } + } +} + +// PEER ID PARSE ERROR ///////////////////////////////////////////////////////// + +#[derive(Debug, thiserror::Error)] +enum PeerIdParseErrorKind { + #[error("unknown peer kind, {0}")] + PeerKind(String), + #[error("invalid UUID, {0}")] + Uuid(uuid::Error), +} + +#[derive(Debug, thiserror::Error)] +#[error("invalid peer identifier: {kind}")] +pub struct PeerIdParseError { + kind: PeerIdParseErrorKind, +} + +// PEER ID ///////////////////////////////////////////////////////////////////// + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PeerId { + peer_kind: PeerKind, + uuid: Uuid, +} + +impl PeerId { + pub const fn peer_kind(&self) -> PeerKind { + self.peer_kind + } + + pub const fn uuid(&self) -> &Uuid { + &self.uuid + } + + pub const fn is_external(&self) -> bool { + self.peer_kind.is_external() + } +} + +impl fmt::Display for PeerId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(prefix) = self.peer_kind.prefix() { + write!(f, "{prefix}_")?; + } + write!(f, "{}", self.uuid.as_hyphenated()) + } +} + +impl str::FromStr for PeerId { + type Err = PeerIdParseError; + + fn from_str(s: &str) -> Result { + let (s, peer_kind) = match s.split_once('_') { + None => (s, PeerKind::default()), + Some((prefix, s)) => match PeerKind::from_prefix(prefix) { + Some(peer_kind) => (s, peer_kind), + None => { + return Err(PeerIdParseError { + kind: PeerIdParseErrorKind::PeerKind(prefix.to_owned()), + }); + } + }, + }; + match s.parse::() { + Ok(hyphenated) => Ok(Self { + uuid: hyphenated.into_uuid(), + peer_kind, + }), + Err(e) => Err(PeerIdParseError { + kind: PeerIdParseErrorKind::Uuid(e), + }), + } + } +} + +impl<'de> Deserialize<'de> for PeerId { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer)? + .parse() + .map_err(serde::de::Error::custom) + } +} + +impl Serialize for PeerId { + fn serialize(&self, serializer: S) -> Result { + self.to_string().serialize(serializer) + } +} diff --git a/src/v4/network_group/wannabe_external_peer.rs b/src/v4/network_group/wannabe_external_peer.rs new file mode 100644 index 0000000..fdc21c8 --- /dev/null +++ b/src/v4/network_group/wannabe_external_peer.rs @@ -0,0 +1,94 @@ +use core::net::IpAddr; + +use oauth10a::client::{ClientError, RestClient}; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +use crate::Client; + +use super::{ + MemberId, OwnerId, network_group_id::NetworkGroupId, peer::PeerId, + wireguard::WireGuardPublicKey, +}; + +// PEER ROLE /////////////////////////////////////////////////////////////////// + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PeerRole { + #[serde(rename = "CLIENT")] + Client, + #[serde(rename = "SERVER")] + Server, +} + +// PEER CREATED //////////////////////////////////////////////////////////////// + +/// Response from [`WannabePeer`] and [`WannabeExternalPeer`] requests. +#[derive(Debug, Deserialize)] +pub struct PeerCreated { + #[serde(rename = "peerId")] + pub peer_id: PeerId, +} + +// ERROR /////////////////////////////////////////////////////////////////////// + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("failed to post Wannabe External Peer for owner '{0}', network group '{1}', {0}")] + Post(String, NetworkGroupId, ClientError), +} + +// WANNABE EXTERNAL PEER /////////////////////////////////////////////////////// + +/// Request to join a Network Group as an external peer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WannabeExternalPeer { + /// Label of a Network Group peer. + #[serde(rename = "label")] + pub label: String, + /// The public IP v4 or v6 on which the peer is listening, + /// when `peer_role` is [`PeerRole::Server`]. + #[serde(rename = "ip", skip_serializing_if = "Option::is_none")] + pub ip: Option, + /// The public TCP or UDP port number on which the peer is listening, + /// when `peer_role` is [`PeerRole::Server`]. + #[serde(rename = "port", skip_serializing_if = "Option::is_none")] + pub port: Option, + /// The role of this peer in the Network Group. + #[serde(rename = "peerRole")] + pub peer_role: PeerRole, + /// Base64-encoded WireGuard public key of the peer. + #[serde(rename = "publicKey")] + pub public_key: WireGuardPublicKey, + /// Host name of the peer. + #[serde(rename = "hostname", skip_serializing_if = "Option::is_none")] + pub hostname: Option, + /// Event that created the peer within the Network Group. + #[serde(rename = "parentEvent", skip_serializing_if = "Option::is_none")] + pub parent_event: Option, + /// Unique ID of a Network Group member. + #[serde(rename = "parentMember")] + pub parent_member: MemberId, +} + +impl WannabeExternalPeer { + pub async fn post( + &self, + client: &Client, + owner_id: &OwnerId, + ng_id: &NetworkGroupId, + ) -> Result { + let endpoint = format!( + "{}/v4/networkgroups/organisations/{owner_id}/networkgroups/{ng_id}/external-peers", + client.endpoint, + ); + + #[cfg(feature = "tracing")] + debug!("execute a request to join Network Group, endpoint: '{endpoint}'"); + + client + .post(&endpoint, self) + .await + .map_err(|e| Error::Post(owner_id.to_owned(), *ng_id, e)) + } +} diff --git a/src/v4/network_group/wireguard.rs b/src/v4/network_group/wireguard.rs new file mode 100644 index 0000000..8ff8d33 --- /dev/null +++ b/src/v4/network_group/wireguard.rs @@ -0,0 +1,194 @@ +use core::{cmp, fmt, hash}; +use std::{io, process::Command}; + +use base64::Engine; +use rand_core::{OsError, OsRng, TryCryptoRng}; +use serde::{Deserialize, Serialize}; +use x25519_dalek::{PublicKey, StaticSecret}; +use zeroize::Zeroizing; + +static REDACTED: &str = ""; + +// WIREGUARD PRIVATE KEY ERROR ///////////////////////////////////////////////// + +/// Error related to [`WireGuardPrivateKey`]. +#[derive(Debug, thiserror::Error)] +pub enum WireGuardPrivateKeyError { + #[error("failed to decode base64-encoded private key, {0}")] + Base64(#[source] base64::DecodeError), + #[error("private key is {0} bytes when it must be exactly 32 bytes")] + InvalidLength(usize), +} + +// WIREGUARD PRIVATE KEY /////////////////////////////////////////////////////// + +/// WireGuard private key. +/// +/// This is a thin wrapper around a Diffie-Hellman secret key as used by WireGuard. +/// +/// * [`serde::Deserialize`] implementation based on the **base64-encoded string** representation +/// * redacted [`fmt::Debug`] and [`fmt::Display`] implementations to prevent +/// accidentally leaking the secret (e.g. in logs) +/// * [`Zeroize`](zeroize::Zeroize) on [`Drop`] +#[derive(Clone)] +pub struct WireGuardPrivateKey(Zeroizing); + +impl WireGuardPrivateKey { + /// Generates a new private key using the given random number generator. + /// + /// # Errors + /// + /// * If `rng` fails to produce 32 random bytes. + pub fn new(rng: &mut R) -> Result { + let mut buf = [0; 32]; + match rng.try_fill_bytes(&mut buf) { + Err(error) => Err(error), + Ok(()) => Ok(Self(Zeroizing::new(StaticSecret::from(buf)))), + } + } + + /// Generates a new private key using operating system's random data source. + /// + /// # Errors + /// + /// * if [`OsRng`] fails to produce 32 random bytes. + pub fn from_os_rng() -> Result { + Self::new(&mut OsRng) + } + + /// Creates a new private key from base64-encoded bytes. + /// + /// # Errors + /// + /// * If decoding the base64-encoded output fails + /// * If decoded output is not 32 bytes long + pub fn from_base64( + input: &(impl ?Sized + AsRef<[u8]>), + ) -> Result { + match base64::engine::general_purpose::STANDARD.decode(input) { + Err(error) => Err(WireGuardPrivateKeyError::Base64(error)), + Ok(bytes) => match <[u8; 32]>::try_from(bytes) { + Err(error) => Err(WireGuardPrivateKeyError::InvalidLength(error.len())), + Ok(array) => Ok(Self(Zeroizing::new(StaticSecret::from(array)))), + }, + } + } + + /// Exposes the private key in a base64-encoded string. + pub fn to_base64(&self) -> Zeroizing { + Zeroizing::new(base64::engine::general_purpose::STANDARD.encode(self.0.as_bytes())) + } + + /// Generates a new private key using `wg genkey` command in a child process. + /// + /// Requires WireGuard command-line interface (`wg`). + /// + /// # Errors + /// + /// * If `wg` command fails + /// * If decoding the base64-encoded output fails + /// * If decoded output is not 32 bytes long + pub fn from_wg_genkey() -> io::Result { + match Command::new("wg").arg("genkey").output() { + Err(error) => Err(error), + Ok(output) => match Self::from_base64(output.stdout.trim_ascii()) { + Err(error) => Err(io::Error::new(io::ErrorKind::InvalidData, error)), + Ok(private_key) => Ok(private_key), + }, + } + } + + /// Returns the associated public key. + pub fn public_key(&self) -> WireGuardPublicKey { + WireGuardPublicKey(PublicKey::from(&*self.0)) + } +} + +impl From<[u8; 32]> for WireGuardPrivateKey { + fn from(value: [u8; 32]) -> Self { + Self(Zeroizing::new(StaticSecret::from(value))) + } +} + +impl TryFrom<&str> for WireGuardPrivateKey { + type Error = WireGuardPrivateKeyError; + + fn try_from(value: &str) -> Result { + Self::from_base64(value) + } +} + +impl<'de> Deserialize<'de> for WireGuardPrivateKey { + fn deserialize>(deserializer: D) -> Result { + let base64_encoded = String::deserialize(deserializer)?; + Self::from_base64(&base64_encoded).map_err(serde::de::Error::custom) + } +} + +// REDACTED implementations + +impl fmt::Display for WireGuardPrivateKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(REDACTED, f) + } +} + +impl fmt::Debug for WireGuardPrivateKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(REDACTED, f) + } +} + +impl hash::Hash for WireGuardPrivateKey { + fn hash(&self, state: &mut H) { + REDACTED.hash(state); + } +} + +impl PartialEq for WireGuardPrivateKey { + fn eq(&self, _other: &Self) -> bool { + false + } +} + +impl PartialOrd for WireGuardPrivateKey { + fn partial_cmp(&self, _other: &Self) -> Option { + None + } +} + +impl Serialize for WireGuardPrivateKey { + fn serialize(&self, serializer: S) -> Result { + REDACTED.serialize(serializer) + } +} + +// WIREGUARD PUBLIC KEY //////////////////////////////////////////////////////// + +/// WireGuard public key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WireGuardPublicKey(pub PublicKey); + +impl Serialize for WireGuardPublicKey { + fn serialize(&self, serializer: S) -> Result { + base64::engine::general_purpose::STANDARD + .encode(self.0) + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for WireGuardPublicKey { + fn deserialize>(deserializer: D) -> Result { + let base64_encoded = String::deserialize(deserializer)?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(&base64_encoded) + .map_err(serde::de::Error::custom)?; + let data = <[u8; 32]>::try_from(decoded).map_err(|error| { + serde::de::Error::custom(format!( + "invalid wireguard public key: length is {} bytes when it must be exactly 32 bytes", + error.len() + )) + })?; + Ok(Self(PublicKey::from(data))) + } +} diff --git a/src/v4/network_group/wireguard_configuration.rs b/src/v4/network_group/wireguard_configuration.rs new file mode 100644 index 0000000..85c968d --- /dev/null +++ b/src/v4/network_group/wireguard_configuration.rs @@ -0,0 +1,144 @@ +use core::{net::IpAddr, str}; + +use base64::Engine; +use oauth10a::client::{ + ClientError, RestClient, + sse::{Json, SseClient, SseStreamBuilder}, +}; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +use crate::Client; + +use super::{OwnerId, network_group_id::NetworkGroupId, peer::PeerId}; + +// ERROR /////////////////////////////////////////////////////////////////////// + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("failed to get WireGuard configuration for owner '{0}', network group '{1}', {0}")] + Get(String, NetworkGroupId, ClientError), + #[error("network group identifier, expected {expected}, found {found}")] + NetworkGroupIdMismatch { + expected: NetworkGroupId, + found: NetworkGroupId, + }, +} + +// PEER VIEW /////////////////////////////////////////////////////////////////// + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerView { + /// the uuid of the instance + #[serde(rename = "peer_id")] + pub peer_id: PeerId, + /// typically "10.101.2.4" + #[serde(rename = "peer_ip")] + pub peer_ip: IpAddr, + /// typically "app_something.m.ng_abcdefg.ng.clever-cloud.com" + #[serde(rename = "peer_hostname")] + pub peer_hostname: String, +} + +// RAW WIREGUARD CONFIG //////////////////////////////////////////////////////// + +/// WireGuard configuration decoded from Base64. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawWireGuardConfig(pub String); + +impl RawWireGuardConfig { + pub const PREFIX: &'static str = "\n####networkgroups start\n"; + pub const SUFFIX: &'static str = "\n####networkgroups end\n"; + pub const WG_PRIVATE_KEY_PLACEHOLDER: &'static str = "<%PrivateKey%>"; +} + +impl AsRef for RawWireGuardConfig { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Serialize for RawWireGuardConfig { + fn serialize(&self, serializer: S) -> Result { + base64::engine::general_purpose::STANDARD + .encode(&self.0) + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for RawWireGuardConfig { + fn deserialize>(deserializer: D) -> Result { + let base64_encoded = String::deserialize(deserializer)?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(&base64_encoded) + .map_err(serde::de::Error::custom)?; + let decoded_utf8 = str::from_utf8(&decoded).map_err(serde::de::Error::custom)?; + Ok(Self(decoded_utf8.trim().to_owned())) + } +} + +// WIREGUARD CONFIGURATION ///////////////////////////////////////////////////// + +/// Event generated by OVD on configuration updates. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WireGuardConfiguration { + #[serde(rename = "ngId")] + pub ng_id: NetworkGroupId, + #[serde(rename = "peerId")] + pub peer_id: PeerId, + #[serde(rename = "peers")] + pub peers: Vec, + #[serde(rename = "version")] + pub version: i32, + #[serde(rename = "configuration")] + pub configuration: RawWireGuardConfig, +} + +impl WireGuardConfiguration { + pub async fn get( + client: &Client, + owner_id: &OwnerId, + ng_id: &NetworkGroupId, + ) -> Result { + let endpoint = format!( + "{}/v4/networkgroups/organisations/{owner_id}/networkgroups/{ng_id}/external-peers", + client.endpoint, + ); + + #[cfg(feature = "tracing")] + debug!("execute a request to get WireGuard configuration, endpoint: '{endpoint}'"); + + let wg_configuration: WireGuardConfiguration = client + .get(&endpoint) + .await + .map_err(|e| Error::Get(owner_id.to_owned(), *ng_id, e))?; + + if wg_configuration.ng_id != *ng_id { + return Err(Error::NetworkGroupIdMismatch { + expected: *ng_id, + found: wg_configuration.ng_id, + }); + } + + Ok(wg_configuration) + } + + pub fn sse( + client: &Client, + owner_id: &OwnerId, + ng_id: &NetworkGroupId, + peer_id: &PeerId, + ) -> SseStreamBuilder> { + let endpoint = format!( + "{}/v4/networkgroups/organisations/{owner_id}/networkgroups/{ng_id}/peers/{peer_id}/wireguard/configuration/stream", + client.endpoint + ); + + #[cfg(feature = "tracing")] + debug!( + "create SSE stream builder to subscribe to WireGuard configuration's, endpoint: '{endpoint}'" + ); + + client.sse(&endpoint) + } +}