diff --git a/Cargo.lock b/Cargo.lock index c1fe2d0cc4..0933e63205 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2900,6 +2900,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_qs", "strum", "tempfile", "thiserror 2.0.3", @@ -4767,6 +4768,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_qs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "serde_spanned" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 5a8107dea6..12d0d6b4ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ reqwest = { version = "0.12", default-features = false, features = [ rstest = "0.18.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.132" +serde_qs = "0.13.0" serde_with = { version = "1.11.0", features = ["base64", "hex"] } serial_test = { version = "3.2.0", features = ["async"] } sha2 = "0.10" diff --git a/docker-compose.yml b/docker-compose.yml index 1e1b7e34a1..d4795277d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: - "8080:8080" volumes: - ./kbs/data/kbs-storage:/opt/confidential-containers/kbs/repository:rw + - ./kbs/data/nebula-ca:/opt/confidential-containers/kbs/nebula-ca:rw - ./kbs/config/public.pub:/opt/confidential-containers/kbs/user-keys/public.pub - ./kbs/config/docker-compose/kbs-config.toml:/etc/kbs-config.toml depends_on: diff --git a/kbs/Cargo.toml b/kbs/Cargo.toml index 3ca6e0e253..f0542770ac 100644 --- a/kbs/Cargo.toml +++ b/kbs/Cargo.toml @@ -33,6 +33,10 @@ aliyun = ["kms/aliyun"] # Use pkcs11 resource backend to store secrets in an HSM pkcs11 = ["cryptoki"] +# Use Nebula Certificate Authority plugin to provide CA services to nodes +# that want to join a Nebula overlay network +nebula-ca-plugin = [] + [dependencies] actix = "0.13.5" actix-web = { workspace = true, features = ["openssl"] } @@ -60,10 +64,12 @@ regorus.workspace = true reqwest = { workspace = true, features = ["json"] } rsa = { version = "0.9.2", features = ["sha2"] } scc = "2" +serde_qs.workspace = true semver = "1.0.16" serde = { workspace = true, features = ["derive"] } serde_json.workspace = true strum.workspace = true +tempfile.workspace = true thiserror.workspace = true time = { version = "0.3.37", features = ["std"] } tokio.workspace = true @@ -90,7 +96,6 @@ attestation-service = { path = "../attestation-service", default-features = fals [dev-dependencies] -tempfile.workspace = true rstest.workspace = true reference-value-provider-service.path = "../rvps" diff --git a/kbs/Makefile b/kbs/Makefile index c4b328b50a..ef8c5bad72 100644 --- a/kbs/Makefile +++ b/kbs/Makefile @@ -1,5 +1,6 @@ AS_TYPE ?= coco-as ALIYUN ?= false +NEBULA_CA_PLUGIN ?= false BUILD_ARCH := $(shell uname -m) ARCH ?= $(shell uname -m) @@ -48,6 +49,10 @@ ifeq ($(ALIYUN), true) FEATURES += aliyun endif +ifeq ($(NEBULA_CA_PLUGIN), true) + FEATURES += nebula-ca-plugin +endif + ifndef CLI_FEATURES ifdef ATTESTER CLI_FEATURES = "sample_only,$(ATTESTER)" diff --git a/kbs/docker/coco-as-grpc/Dockerfile b/kbs/docker/coco-as-grpc/Dockerfile index 36d53c25ee..124e4b1248 100644 --- a/kbs/docker/coco-as-grpc/Dockerfile +++ b/kbs/docker/coco-as-grpc/Dockerfile @@ -2,6 +2,8 @@ FROM --platform=$BUILDPLATFORM docker.io/library/rust:latest AS builder ARG BUILDPLATFORM=linux/amd64 ARG ARCH=x86_64 ARG ALIYUN=false +ARG NEBULA_CA_PLUGIN=false +ARG NEBULA_VERSION=v1.9.5 WORKDIR /usr/src/kbs COPY . . @@ -17,11 +19,18 @@ RUN if [ $(uname -m) != ${ARCH} ]; then \ apt-get install -y libssl-dev:${OS_ARCH}; fi # Build and Install KBS -RUN cd kbs && make AS_FEATURE=coco-as-grpc ALIYUN=${ALIYUN} ARCH=${ARCH} && \ +RUN cd kbs && make AS_FEATURE=coco-as-grpc ALIYUN=${ALIYUN} ARCH=${ARCH} NEBULA_CA_PLUGIN=${NEBULA_CA_PLUGIN} && \ make ARCH=${ARCH} install-kbs +# Download and install Nebula +RUN if [ "${NEBULA_CA_PLUGIN}" = "true" ]; then \ + curl -fSLO https://github.com/slackhq/nebula/releases/download/${NEBULA_VERSION}/nebula-$(echo ${BUILDPLATFORM} | sed 's/\//-/').tar.gz && \ + tar -C /usr/local/bin -xzf nebula-$(echo "${BUILDPLATFORM}" | sed 's/\//-/').tar.gz; \ + fi + FROM ubuntu:22.04 LABEL org.opencontainers.image.source="https://github.com/confidential-containers/trustee/kbs" COPY --from=builder /usr/local/bin/kbs /usr/local/bin/kbs +COPY --from=builder /usr/local/bin/nebula-cert* /usr/local/bin/nebula-cert diff --git a/kbs/docs/config.md b/kbs/docs/config.md index 3cd3236c9e..61cf33f511 100644 --- a/kbs/docs/config.md +++ b/kbs/docs/config.md @@ -250,6 +250,42 @@ This is also called "Repository" in old versions. The properties to be configure | `password` | String | AAP client key password | Yes | `8f9989c18d27...` | | `cert_pem` | String | CA cert for the KMS instance | Yes | `-----BEGIN CERTIFICATE----- ...` | +#### Nebula CA Configuration + +The Nebula CA plugin can be enabled by adding the following to the KBS config. + +```yaml +[[plugins]] +name = "nebula-ca" +``` + +The properties below can be used to further configure the plugin. They are optional. + +| Property | Type | Description | Default | +|------------------------|--------|-----------------------------------|----------| +| `nebula_cert_bin_path` | String | `nebula-cert` binary path | If not provided, `nebula-cert` will be searched in $PATH | +| `work_dir` | String | This plugin work directory, it requires `rw` permission | `/opt/confidential-containers/kbs/nebula-ca` | +| `[plugins.self_signed_ca]` | SubSection | Properties used to create the Nebula CA key and certificate | See table below | + +The properties below can be defined under `[plugins.self_signed_ca]` to override their default value. They are optional. + +| Property | Type | Description | Default | Example | +|---------------------|---------|-----------------------------------|----------|-----------------------------------------------------| +| `name` | String | Name of the certificate authority | `Trustee Nebula CA plugin` | | +| `argon_iterations` | Integer | Argon2 iterations parameter used for encrypted private key passphrase | 1 | | +| `argon_memory` | Integer | Argon2 memory parameter (in KiB) used for encrypted private key passphrase | 2097152 | | +| `argon_parallelism` | Integer | Argon2 parallelism parameter used for encrypted private key passphrase | 4 | | +| `curve` | String | EdDSA/ECDSA Curve (25519, P256) | `25519` | | +| `duration` | String | Amount of time the certificate should be valid for. Valid time units are: "h""m""s" | `8760h0m0s` | | +| `groups` | String | Comma separated list of groups. This will limit which groups subordinate certs can use | "" | `server,ssh` | +| `ips` | String | Comma separated list of ipv4 address and network in CIDR notation. This will limit which ipv4 addresses and networks subordinate certs can use for ip addresses | "" | `192.168.100.10/24,192.168.100.15/24` | +| `out_qr` | String | Path to write a QR code image (png) of the certificate | | `/opt/confidential-containers/kbs/nebula-ca/ca/ca_qr.crt`| +| `subnets` | String | Comma separated list of ipv4 address and network in CIDR notation. This will limit which ipv4 addresses and networks subordinate certs can use in subnets | "" | `192.168.86.0/24` | + +The Nebula CA key and certificate are stored in `${work_dir}/ca/ca.{key,crt}`. If these files were generated in a previous run or [generated out-of-band](https://nebula.defined.net/docs/guides/quick-start/#creating-your-first-certificate-authority), the plugin will just (re-)use them; otherwise, the plugin will generate new ones by calling the `nebula-cert` binary with the `[plugins.self_signed_ca]` properties. + +Detailed [documentation](#kbs/docs/plugins/nebula_ca.md). + ## Configuration Examples Using a built-in CoCo AS: @@ -338,6 +374,47 @@ type = "LocalFs" dir_path = "/opt/confidential-containers/kbs/repository" ``` +Using Nebula CA plugin: + +```toml +[http_server] +sockets = ["0.0.0.0:8080"] +insecure_http = true + +[admin] +insecure_api = true + +[attestation_token] + +[attestation_service] +type = "coco_as_builtin" +work_dir = "/opt/confidential-containers/attestation-service" +policy_engine = "opa" + + [attestation_service.attestation_token_broker] + type = "Ear" + duration_min = 5 + + [attestation_service.rvps_config] + type = "BuiltIn" + + [attestation_service.rvps_config.storage] + type = "LocalFs" + +[[plugins]] +name = "resource" +type = "LocalFs" +dir_path = "/opt/confidential-containers/kbs/repository" + +[[plugins]] +name = "nebula-ca" +# If the Nebula CA key and certificate don't exist yet, the plugin will create them +# using the default configurations, which can be overriden here, +# e.g. the duration of the root CA. +#[plugin.self_signed_ca] +#duration = "4380hm0s0" +``` + Distributing resources in Passport mode: ```toml diff --git a/kbs/docs/plugins/nebula_ca.md b/kbs/docs/plugins/nebula_ca.md new file mode 100644 index 0000000000..33b5ff7154 --- /dev/null +++ b/kbs/docs/plugins/nebula_ca.md @@ -0,0 +1,72 @@ +# Nebula CA plugin + +[Nebula](https://github.com/slackhq/nebula) is an open-source project that provides +tooling to create a Layer 3 Encrypted Nebula Overlay Network (ENON). Each Nebula release +provides two binaries. +- nebula: it's used to create nodes (Lighthouse or regular node) and +join to a Lighthouse's ENON +- nebula-cert: executable to generate keys, certificates, CA's, and to sign node certificates. + +This plugin calls the `nebula-cert` binary to provide some of its CA functionalities for +nodes (e.g. CoCo PODs or confidential VMs) that want to join an ENON. + +Every ENON must have at least one Lighthouse, which is a node that has an static IP address, identifies the ENON and helps with node discovery. + +## Setup + +1. Build the KBS with the cargo feature `nebula-ca-plugin` enabled and install the `nebula-cert` binary to the KBS image. + +```bash +docker compose build --build-arg NEBULA_CA_PLUGIN=true +``` + +2. Configure the `nebula-ca` plugin. For simple cases, the plugin default configurations should be enough, just add the lines below to the [KBS config](#kbs/config/docker-compose/kbs-config.toml). For more complex cases, see the [config.md](#kbs/docs/config.md). + +```toml +[[plugins]] +name = "nebula-ca" +``` + +3. Start trustee + +```bash +docker compose up +``` + +## Runtime services + +All runtime services supported are described in the following sections. + +### credential service + +Create a credential for the node to join an ENON. + +Only `GET` request is supported, e.g. `GET /kbs/v0/nebula-ca/credential?name=podA&ip=10.9.8.7/21`. + +The request takes parameters via URL query string. All parameters supported are described in the table below. Note that `name` and `ip` are required. + +| Property | Type | Required | Description | Default | Example | +|---------------------|--------|----------|-------------------------|---------|-------------------------------------------| +| `name` | String | Yes | Name of the certificate, usually hostname or podname | | `credential?name=podA&ip=10.9.8.7/21` | +| `ip` | String | Yes | IPv4 address and network in CIDR notation to assign to the certificate | | `credential?name=podA&ip=10.9.8.7/21` | +| `duration` | String | No | How long the certificate should be valid for. | 1 second before the signing certificate expires. Valid time units are: "h""m""s" | `credential?name=podA&ip=10.9.8.7/21&duration=8760h0m0s` | +| `groups` | String | No | Comma separated list of groups | | `credential?name=podA&ip=10.9.8.7/21&groups=ssh,server` | +| `subnets` | String | No | Comma separated list of IPv4 address and network in CIDR notation. Subnets the certificate can serve for. | | `credential?name=podA&ip=10.9.8.7/21&subnets=10.9.7.7/21,10.9.8.7/21` | + +The request will be processed only if the node passes the attestation, otherwise an error is returned. With that, the ENON is expected to have only attested nodes. + +Once the request is processed, the following structure is returned in JSON format. + +```rust +struct CredentialServiceOut { + node_crt: Vec, // Self-signed certificate created + node_key: Vec, // Key created + ca_crt: Vec, // CA certificate +} +``` + +Currently, this service provides only basic functionality. +- It is stateless. Once a requested credential is returned, it is deleted. +- It does not support [CA rotation](https://nebula.defined.net/docs/guides/rotating-certificate-authority/). +- It does not support runtime attestation. If the same POD requests another credential later, the changes made to the POD's initial state will not be attested. Ideally, the POD should make sure that the certificate will not expire before the workload is finished. +- It does not have any information about Lighthouses, so it is not able to check if the IP address provided in the request and the IP address of the Lighthouse are in the same network. \ No newline at end of file diff --git a/kbs/src/plugins/implementations/mod.rs b/kbs/src/plugins/implementations/mod.rs index 8bf856bbf1..f39863718f 100644 --- a/kbs/src/plugins/implementations/mod.rs +++ b/kbs/src/plugins/implementations/mod.rs @@ -2,8 +2,12 @@ // Licensed under the Apache License, Version 2.0, see LICENSE for details. // SPDX-License-Identifier: Apache-2.0 +#[cfg(feature = "nebula-ca-plugin")] +pub mod nebula_ca; pub mod resource; pub mod sample; +#[cfg(feature = "nebula-ca-plugin")] +pub use nebula_ca::{NebulaCaPlugin, NebulaCaPluginConfig}; pub use resource::{RepositoryConfig, ResourceStorage}; pub use sample::{Sample, SampleConfig}; diff --git a/kbs/src/plugins/implementations/nebula_ca.rs b/kbs/src/plugins/implementations/nebula_ca.rs new file mode 100644 index 0000000000..25e3554d11 --- /dev/null +++ b/kbs/src/plugins/implementations/nebula_ca.rs @@ -0,0 +1,537 @@ +// Copyright (c) 2025 by IBM Corporation +// Licensed under the Apache License, Version 2.0, see LICENSE for details. +// SPDX-License-Identifier: Apache-2.0 + +//! Nebula CA plugin. +//! +//! This plugin calls the `nebula-cert` binary to provide some of its CA +//! functionalities for nodes (e.g. CoCo PODs or confidential VMs) that +//! want join an encrypted Nebula overlay network. More information can +//! be found in the [plugin](#kbs/docs/plugins/nebula_ca.md) documentation. +use actix_web::http::Method; +use anyhow::{anyhow, bail, Context, Error, Result}; +use semver::{Version, VersionReq}; +use std::{ + ffi::OsString, + fs, + path::{Path, PathBuf}, + process::Command, +}; +use tempfile::tempdir_in; + +use crate::plugins::plugin_manager::ClientPlugin; + +/// Default Nebula CA name +const DEFAULT_NEBULA_CA_NAME: &str = "Trustee Nebula CA plugin"; +/// By default we search `nebula-cert` in the $PATH. +const DEFAULT_NEBULA_CERT_PATH: &str = "nebula-cert"; +/// Default Nebula CA working directory. +/// It must have read-write permission. +const DEFAULT_WORK_DIR: &str = "/opt/confidential-containers/kbs/nebula-ca"; +/// Minimum nebula-cert version required. +const NEBULA_CERT_VERSION_REQUIREMENT: &str = ">=1.9.5"; + +macro_rules! add_option_string_arg { + ($args_vec:ident, $arg_name:literal, $arg_value:expr) => { + if let Some(v) = $arg_value { + $args_vec.extend_from_slice(&[$arg_name.into(), v.into()]) + } + }; +} + +macro_rules! add_option_u32_arg { + ($args_vec:ident, $arg_name:literal, $arg_value:expr) => { + if let Some(v) = $arg_value { + $args_vec.extend_from_slice(&[$arg_name.into(), v.to_string().into()]) + } + }; +} + +/// Credential service parameters +/// +/// They are provided in the request via URL query string. Only name and ip are required. +/// They match the "./nebula-cert sign <...>" parameters. +#[derive(Debug, PartialEq, serde::Deserialize)] +pub struct NebulaCredentialParams { + /// Required: name of the cert, usually hostname or podname + name: String, + /// Required: IPv4 address and network in CIDR notation to assign the cert + ip: String, + /// Optional: how long the cert should be valid for. + /// The default is 1 second before the signing cert expires. + /// Valid time units are seconds: "s", minutes: "m", hours: "h". + duration: Option, + /// Optional: comma separated list of groups. + groups: Option, + /// Optional: comma separated list of ipv4 address and network in CIDR notation. + /// Subnets this cert can serve for + subnets: Option, +} + +impl TryFrom<&str> for NebulaCredentialParams { + type Error = Error; + + fn try_from(query: &str) -> Result { + let params: NebulaCredentialParams = serde_qs::from_str(query)?; + Ok(params) + } +} + +impl From<&NebulaCredentialParams> for Vec { + fn from(params: &NebulaCredentialParams) -> Self { + let mut args: Vec = vec![ + "-name".into(), + params.name.as_str().into(), + "-ip".into(), + params.ip.as_str().into(), + ]; + + add_option_string_arg!(args, "-duration", ¶ms.duration); + add_option_string_arg!(args, "-groups", ¶ms.groups); + add_option_string_arg!(args, "-subnets", ¶ms.subnets); + + args + } +} + +#[derive(Clone, Debug, serde::Deserialize, PartialEq)] +pub struct NebulaCaPluginConfig { + work_dir: Option, + nebula_cert_bin_path: Option, + ca_config: Option, +} + +impl TryFrom for NebulaCaPlugin { + type Error = Error; + + fn try_from(config: NebulaCaPluginConfig) -> Result { + let work_dir = PathBuf::from(config.work_dir.unwrap_or(DEFAULT_WORK_DIR.into())); + let path = PathBuf::from( + config + .nebula_cert_bin_path + .unwrap_or(DEFAULT_NEBULA_CERT_PATH.into()), + ); + let crt: PathBuf = work_dir.join("ca/ca.crt"); + let key: PathBuf = work_dir.join("ca/ca.key"); + + let nebula = NebulaCertBin { path }; + + // Check minimum nebula-cert version requirement + let version: String = nebula.version_checked()?; + log::info!("nebula-cert version: {}", version); + + if let Some(parent) = crt.as_path().parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Create {} dir", parent.display()))?; + } + + // Create self-signed certificate authority + if !crt.exists() && !key.exists() { + nebula.ca( + &config.ca_config.unwrap_or_default(), + crt.as_path(), + key.as_path(), + )?; + log::info!("Self-signed Nebula CA created"); + } + + // Check the provided or created Nebula CA exists. + if !crt.exists() || !key.exists() { + bail!("Nebula CA not found"); + } + + log::info!("Nebula CA key: {}", key.display()); + log::info!( + "Nebula CA certificate: {}\n{}", + crt.display(), + nebula.print(&crt)? + ); + + Ok(NebulaCaPlugin { + nebula, + crt, + key, + work_dir, + }) + } +} + +/// Nebula CA configuration +/// +/// These properties can be provided in the KBS config +/// under [plugins.self_signed_ca]. They are optional +/// and match the `nebula-cert ca <...>` parameters. +#[derive(Clone, Debug, Default, serde::Deserialize, PartialEq)] +struct SelfSignedNebulaCaConfig { + /// Name of the certificate authority + name: Option, + /// Argon2 iterations parameter used for encrypted private key passphrase + argon_iterations: Option, + /// Argon2 memory parameter (in KiB) used for encrypted private key passphrase + argon_memory: Option, + /// Argon2 parallelism parameter used for encrypted private key passphrase + argon_parallelism: Option, + /// EdDSA/ECDSA Curve (25519, P256) + curve: Option, + /// Amount of time the certificate should be valid for. Valid time units are: "h""m""s" + duration: Option, + /// Comma separated list of groups. This will limit which groups subordinate certs can use + groups: Option, + /// Comma separated list of ipv4 address and network in CIDR notation. + /// This will limit which ipv4 addresses and networks subordinate certs can use for ip addresses + ips: Option, + /// Output a QR code image (png) of the certificate + out_qr: Option, + /// Comma separated list of ipv4 address and network in CIDR notation. + /// This will limit which ipv4 addresses and networks subordinate certs can use in subnets + subnets: Option, +} + +impl From<&SelfSignedNebulaCaConfig> for Vec { + fn from(ca: &SelfSignedNebulaCaConfig) -> Self { + let mut args: Vec = Vec::new(); + + // "-name" is required in the cmdline + let name: String = ca + .name + .clone() + .unwrap_or(DEFAULT_NEBULA_CA_NAME.to_string()); + args.extend_from_slice(&["-name".into(), name.into()]); + + add_option_u32_arg!(args, "-argon-iterations", &ca.argon_iterations); + add_option_u32_arg!(args, "-argon-memory", &ca.argon_memory); + add_option_u32_arg!(args, "-argon-parallelism", &ca.argon_parallelism); + add_option_string_arg!(args, "-curve", &ca.curve); + add_option_string_arg!(args, "-duration", &ca.duration); + add_option_string_arg!(args, "-groups", &ca.groups); + add_option_string_arg!(args, "-ips", &ca.ips); + add_option_string_arg!(args, "-out-qr", &ca.out_qr); + add_option_string_arg!(args, "-subnets", &ca.subnets); + + args + } +} + +#[derive(Debug)] +struct NebulaCertBin { + path: PathBuf, +} + +impl NebulaCertBin { + /// Create self-signed certificate authority + pub fn ca(&self, config: &SelfSignedNebulaCaConfig, crt: &Path, key: &Path) -> Result<()> { + let mut args: Vec = Vec::from(config); + args.extend_from_slice(&["-out-crt".into(), crt.into(), "-out-key".into(), key.into()]); + let mut cmd = Command::new(self.path.as_path()); + cmd.arg("ca").args(&args); + let status = cmd + .status() + .context(format!("{} ca {:?}", self.path.display(), &args))?; + if !status.success() { + bail!("{} ca {:?}", self.path.display(), &args); + } + Ok(()) + } + + /// Print details about provided certificate + pub fn print(&self, crt: &Path) -> Result { + let args: Vec = vec!["-path".into(), crt.into()]; + let mut cmd = Command::new(self.path.as_path()); + cmd.arg("print").args(&args); + let output = cmd + .output() + .context(format!("{} print {:?}", self.path.display(), &args))?; + if !output.status.success() { + bail!("{} print {:?}", self.path.display(), &args); + } + let cert_details = String::from_utf8(output.stdout)?; + + Ok(cert_details.trim_end().to_string()) + } + + /// Create and sign a certificate + pub async fn sign( + &self, + params: &NebulaCredentialParams, + ca_key: &Path, + ca_crt: &Path, + node_key: &Path, + node_crt: &Path, + ) -> Result<()> { + let mut args: Vec = Vec::from(params); + args.extend_from_slice(&[ + "-ca-key".into(), + ca_key.into(), + "-ca-crt".into(), + ca_crt.into(), + "-out-key".into(), + node_key.into(), + "-out-crt".into(), + node_crt.into(), + ]); + let mut cmd = tokio::process::Command::new(self.path.as_path()); + cmd.arg("sign").args(&args); + let status = + cmd.status() + .await + .context(format!("{} sign {:?}", self.path.display(), &args))?; + if !status.success() { + bail!("{} sign {:?}", self.path.display(), &args); + } + Ok(()) + } + + /// Verify if the node certificate isn't expired and was signed by the CA. + pub async fn verify(&self, ca_crt: &Path, node_crt: &Path) -> Result<()> { + let args: Vec = vec!["-ca".into(), ca_crt.into(), "-crt".into(), node_crt.into()]; + let mut cmd = Command::new(self.path.as_path()); + cmd.arg("verify").args(&args); + let status = cmd + .status() + .context(format!("{} verify {:?}", self.path.display(), &args))?; + if !status.success() { + bail!("{} verify {:?}", self.path.display(), &args); + } + Ok(()) + } + + // Get version + pub fn version(&self) -> Result { + let output = Command::new(self.path.as_path()) + .arg("--version") + .output() + .context(format!("'{} --version' failed to run", self.path.display()))?; + + if !output.status.success() { + bail!("'{} --version' failed to complete", self.path.display()); + } + + let version = String::from_utf8(output.stdout)?; + + Ok(version + .strip_prefix("Version: ") + .context("Failed to parse Nebula version")? + .trim_end() + .to_string()) + } + + /// Get version, but check if it satisfies the version requirements. + pub fn version_checked(&self) -> Result { + let version = self.version()?; + + // Check if the version satisfies the requirements + let version_req = VersionReq::parse(NEBULA_CERT_VERSION_REQUIREMENT)?; + if !version_req.matches(&Version::parse(version.as_str())?) { + bail!( + "nebula-ca version requirement not satisfied: {} {}", + version, + NEBULA_CERT_VERSION_REQUIREMENT + ); + } + + Ok(version) + } +} + +/// Credential service return type +#[derive(Debug, serde::Serialize)] +pub struct CredentialServiceOut { + pub node_crt: Vec, + pub node_key: Vec, + pub ca_crt: Vec, +} + +/// Nebula CA plugin object +#[derive(Debug)] +pub struct NebulaCaPlugin { + nebula: NebulaCertBin, + key: PathBuf, + crt: PathBuf, + work_dir: PathBuf, +} + +impl NebulaCaPlugin { + pub async fn create_credential( + &self, + node_key: &Path, + node_crt: &Path, + params: &NebulaCredentialParams, + ) -> Result { + // Create certificate and sign it + self.nebula + .sign( + params, + self.key.as_path(), + self.crt.as_path(), + node_key, + node_crt, + ) + .await + .context("Failed to create credential")?; + + // Verify generated certificate + self.nebula + .verify(self.crt.as_path(), node_crt) + .await + .context("Failed to verify credential")?; + + let credential = CredentialServiceOut { + node_crt: tokio::fs::read(node_crt) + .await + .context(format!("read {}", node_crt.display()))?, + node_key: tokio::fs::read(node_key) + .await + .context(format!("read {}", node_key.display()))?, + ca_crt: tokio::fs::read(self.crt.as_path()) + .await + .context(format!("read {}", self.crt.display()))?, + }; + + Ok(credential) + } +} + +#[async_trait::async_trait] +impl ClientPlugin for NebulaCaPlugin { + async fn handle( + &self, + _body: &[u8], + query: &str, + path: &str, + method: &Method, + ) -> Result> { + let sub_path = path + .strip_prefix('/') + .context("accessed path is illegal, should start with `/`")?; + if method.as_str() != "GET" { + bail!("Illegal HTTP method. Only GET is supported"); + } + + // The Nebula CA plugin is stateless, so none of request types below should + // store state. + match sub_path { + // Create credential for the provided parameters. + // The credential directory (and its files) is auto-deleted after the Credential is returned. + "credential" => { + let params = NebulaCredentialParams::try_from(query)?; + + let credential_dir = tempdir_in(self.work_dir.as_path())?; + let node_key: PathBuf = credential_dir.path().to_owned().join("node.key"); + let node_crt: PathBuf = credential_dir.path().to_owned().join("node.crt"); + + let credential = self + .create_credential(node_key.as_path(), node_crt.as_path(), ¶ms) + .await?; + + Ok(serde_json::to_vec(&credential)?) + } + _ => Err(anyhow!("{} not supported", sub_path))?, + } + } + + async fn validate_auth( + &self, + _body: &[u8], + _query: &str, + _path: &str, + _method: &Method, + ) -> Result { + Ok(false) + } + + async fn encrypted( + &self, + _body: &[u8], + _query: &str, + _path: &str, + _method: &Method, + ) -> Result { + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use std::ffi::OsString; + + use super::NebulaCredentialParams; + + #[rstest] + #[case( + "name=pod1&ip=1.2.3.4/21", + Some(vec![ + "-name".into(), + "pod1".into(), + "-ip".into(), + "1.2.3.4/21".into() + ]) + )] + #[case( + "name=pod1&ip=1.2.3.4", + Some(vec![ + "-name".into(), + "pod1".into(), + "-ip".into(), + "1.2.3.4".into() + ]) + )] + #[case( + "name=pod1&ip=1.2", + Some(vec![ + "-name".into(), + "pod1".into(), + "-ip".into(), + "1.2".into() + ]) + )] + #[case("name=pod1", None)] + #[case( + "name=pod1&ip=1.2.3.4/21&duration=8760h10m10s", + Some(vec![ + "-name".into(), + "pod1".into(), + "-ip".into(), + "1.2.3.4/21".into(), + "-duration".into(), + "8760h10m10s".into(), + ]) + )] + #[case( + "name=pod1&ip=1.2.3.4/21&groups=server,ssh", + Some(vec![ + "-name".into(), + "pod1".into(), + "-ip".into(), + "1.2.3.4/21".into(), + "-groups".into(), + "server,ssh".into(), + ]) + )] + #[case( + "name=pod1&ip=1.2.3.4/21&subnets=1.2.3.5/21,1.2.3.6/21", + Some(vec![ + "-name".into(), + "pod1".into(), + "-ip".into(), + "1.2.3.4/21".into(), + "-subnets".into(), + "1.2.3.5/21,1.2.3.6/21".into(), + ]) + )] + + /// Take credential service parameters provided as a URL query string + /// and convert them to parameters for `nebula-cert sign ` + fn test_credential_service_params( + #[case] query: &str, + #[case] expected: Option>, + ) { + let credential_params = NebulaCredentialParams::try_from(query); + if expected.is_none() { + assert!(credential_params.is_err()) + } else { + let cmd_args: Vec = Vec::from(&credential_params.unwrap()); + assert_eq!(cmd_args, expected.unwrap()) + } + } +} diff --git a/kbs/src/plugins/plugin_manager.rs b/kbs/src/plugins/plugin_manager.rs index f558aa64f8..17f2083783 100644 --- a/kbs/src/plugins/plugin_manager.rs +++ b/kbs/src/plugins/plugin_manager.rs @@ -10,6 +10,9 @@ use serde::Deserialize; use super::{sample, RepositoryConfig, ResourceStorage}; +#[cfg(feature = "nebula-ca-plugin")] +use super::{NebulaCaPlugin, NebulaCaPluginConfig}; + type ClientPluginInstance = Arc; #[async_trait::async_trait] @@ -59,6 +62,10 @@ pub enum PluginsConfig { #[serde(alias = "resource")] ResourceStorage(RepositoryConfig), + + #[cfg(feature = "nebula-ca-plugin")] + #[serde(alias = "nebula-ca")] + NebulaCaPlugin(NebulaCaPluginConfig), } impl Display for PluginsConfig { @@ -66,6 +73,8 @@ impl Display for PluginsConfig { match self { PluginsConfig::Sample(_) => f.write_str("sample"), PluginsConfig::ResourceStorage(_) => f.write_str("resource"), + #[cfg(feature = "nebula-ca-plugin")] + PluginsConfig::NebulaCaPlugin(_) => f.write_str("nebula-ca"), } } } @@ -85,6 +94,12 @@ impl TryInto for PluginsConfig { .context("Initialize 'Resource' plugin failed")?; Arc::new(resource_storage) as _ } + #[cfg(feature = "nebula-ca-plugin")] + PluginsConfig::NebulaCaPlugin(nebula_ca_config) => { + let nebula_ca = NebulaCaPlugin::try_from(nebula_ca_config) + .context("Initialize 'nebula-ca-plugin' failed")?; + Arc::new(nebula_ca) as _ + } }; Ok(plugin)