diff --git a/crates/ev-cli/src/commands/enclave/init.rs b/crates/ev-cli/src/commands/enclave/init.rs index 06725106..85d94a29 100644 --- a/crates/ev-cli/src/commands/enclave/init.rs +++ b/crates/ev-cli/src/commands/enclave/init.rs @@ -1,11 +1,13 @@ +use std::num::{NonZeroU32, NonZeroU64}; + use clap::{ArgGroup, Parser}; use common::api::BasicAuth; use common::{api::AuthMode, CliError}; use ev_enclave::api::enclave::{Enclave, EnclaveApi}; use ev_enclave::cert::{create_new_cert, DesiredLifetime, DistinguishedName}; use ev_enclave::config::{ - default_dockerfile, EgressSettings, EnclaveConfig, HealthcheckConfig, ScalingSettings, - ServiceSettings, SigningInfo, + default_dockerfile, AcceptorConfig, EgressSettings, EnclaveConfig, HealthcheckConfig, + ScalingSettings, ServiceSettings, SigningInfo, }; /// Initialize an Enclave.toml in the current directory @@ -93,6 +95,18 @@ pub struct InitArgs { /// The port that all incoming traffic should be forwarded to within the Enclave. #[arg(long = "port")] pub port: Option, + + /// Cap on the number of in-flight connections the Enclave will accept concurrently. + #[arg(long)] + pub max_concurrent_connections: Option, + + /// Cap on the number of in-flight TLS handshakes the Enclave will perform concurrently. Must not exceed --max-concurrent-connections. + #[arg(long)] + pub max_concurrent_handshakes: Option, + + /// Per-handshake timeout in milliseconds for incoming TLS handshakes. + #[arg(long)] + pub handshake_timeout: Option, } impl std::convert::From for EnclaveConfig { @@ -115,6 +129,19 @@ impl std::convert::From for EnclaveConfig { val.healthcheck.map(HealthcheckConfig::from) }; + let acceptor = if val.max_concurrent_connections.is_none() + && val.max_concurrent_handshakes.is_none() + && val.handshake_timeout.is_none() + { + None + } else { + Some(AcceptorConfig { + max_concurrent_connections: val.max_concurrent_connections, + max_concurrent_handshakes: val.max_concurrent_handshakes, + handshake_timeout: val.handshake_timeout, + }) + }; + EnclaveConfig { name: val.enclave_name, uuid: None, @@ -138,6 +165,7 @@ impl std::convert::From for EnclaveConfig { healthcheck, service: val.port.map(ServiceSettings::new), attestation_cors: None, + acceptor, } } } @@ -171,6 +199,14 @@ async fn init_local_config(init_args: InitArgs, created_enclave: Enclave) -> exi let config_path = output_path.join("enclave.toml"); let mut initial_config: EnclaveConfig = init_args.into(); + + if let Some(acceptor) = initial_config.acceptor.as_ref() { + if let Err(e) = acceptor.validate() { + log::error!("{e}"); + return e.exitcode(); + } + } + initial_config.annotate(created_enclave); if initial_config.signing.is_none() { @@ -237,6 +273,9 @@ mod init_tests { healthcheck: None, healthcheck_port: None, port: None, + max_concurrent_connections: None, + max_concurrent_handshakes: None, + handshake_timeout: None, } } @@ -383,6 +422,59 @@ keyPath = "./key.pem" assert_eq!(config_content, expected_config_content); } + #[tokio::test] + async fn init_local_config_test_with_acceptor_config() { + let output_dir = TempDir::new().unwrap(); + let sample_enclave = Enclave { + uuid: "1234".into(), + name: "hello-enclave".into(), + team_uuid: "1234".into(), + app_uuid: "1234".into(), + domain: "hello.com".into(), + state: EnclaveState::Pending, + created_at: "00:00:00".into(), + updated_at: "00:00:00".into(), + }; + let mut init_args = default_init_args(&output_dir); + init_args.max_concurrent_connections = NonZeroU32::new(100); + init_args.max_concurrent_handshakes = NonZeroU32::new(10); + init_args.handshake_timeout = NonZeroU64::new(10000); + init_local_config(init_args, sample_enclave).await; + let config_path = output_dir.path().join("enclave.toml"); + assert!(config_path.exists()); + let config_content = String::from_utf8(read(config_path).unwrap()).unwrap(); + let expected_config_content = r#"version = 1 +name = "hello" +uuid = "1234" +app_uuid = "1234" +team_uuid = "1234" +debug = false +dockerfile = "Dockerfile" +api_key_auth = true +trx_logging = true +tls_termination = true +forward_proxy_protocol = false +trusted_headers = ["X-Evervault-*"] + +[egress] +enabled = true +destinations = ["evervault.com"] + +[scaling] +desired_replicas = 2 + +[signing] +certPath = "./cert.pem" +keyPath = "./key.pem" + +[acceptor] +max_concurrent_connections = 100 +max_concurrent_handshakes = 10 +handshake_timeout = 10000 +"#; + assert_eq!(config_content, expected_config_content); + } + #[tokio::test] async fn init_local_config_test_with_service_port() { let output_dir = TempDir::new().unwrap(); diff --git a/crates/ev-enclave/src/build/mod.rs b/crates/ev-enclave/src/build/mod.rs index 85189f84..9f2011e1 100644 --- a/crates/ev-enclave/src/build/mod.rs +++ b/crates/ev-enclave/src/build/mod.rs @@ -255,7 +255,7 @@ async fn process_dockerfile( // catch for edge case where port is defined in the toml, but was never exposed in docker if let Some(false) = is_configured_port_missing_in_docker { log::warn!( - "Found service port in enclave.toml which is not exposed in the supplied Dockerfile. This may suggest a misconfiguration. The build will continue using the service port defined in the enclave.toml file.", + "Found service port in enclave.toml which is not exposed in the supplied Dockerfile. This may suggest a misconfiguration. The build will continue using the service port defined in the enclave.toml file.", ); log::warn!( "Service port from enclave.toml: {}", @@ -341,6 +341,29 @@ async fn process_dockerfile( dataplane_info["healthcheck_use_tls"] = json!(build_config.use_tls_healthcheck()); } + if let Some(acceptor) = build_config.acceptor() { + let mut acc = serde_json::Map::new(); + if let Some(c) = acceptor.max_concurrent_connections { + acc.insert("max_concurrent_connections".into(), json!(c)); + } + if let Some(h) = acceptor.max_concurrent_handshakes { + acc.insert("max_concurrent_handshakes".into(), json!(h)); + } + if let Some(ms) = acceptor.handshake_timeout { + let ms = ms.get(); + acc.insert( + "handshake_timeout".into(), + json!({ + "secs": ms / 1000, + "nanos": (ms % 1000) * 1_000_000, + }), + ); + } + if !acc.is_empty() { + dataplane_info["acceptor"] = Value::Object(acc); + } + } + let dataplane_env = format!( "echo {} > /etc/dataplane-config.json", dataplane_info.to_string().replace('"', "\\\"") @@ -513,6 +536,8 @@ mod test { use crate::version::EnclaveRuntime; use common::enclave::types::AttestationCors; use std::iter::zip; + use std::num::NonZeroU32; + use std::num::NonZeroU64; use tempfile::TempDir; fn get_config(egress_enabled: bool) -> ValidatedEnclaveBuildConfig { @@ -550,6 +575,7 @@ mod test { attestation_cors: Some(AttestationCors { origin: "test.com".to_string(), }), + acceptor: None, } } @@ -1366,6 +1392,76 @@ ENTRYPOINT ["/bootstrap", "1>&2"] } } + #[tokio::test] + async fn test_process_dockerfile_with_acceptor_config() { + let sample_dockerfile_contents = r#"FROM alpine +RUN touch /hello-script;\ + /bin/sh -c "echo -e '"'#!/bin/sh\nwhile true; do echo "hello"; sleep 2; done;\n'"' > /hello-script" +ENTRYPOINT ["sh", "/hello-script"]"#; + let mut readable_contents = sample_dockerfile_contents.as_bytes(); + + let mut config = get_config(false); + config.acceptor = Some(crate::config::AcceptorConfig { + max_concurrent_connections: NonZeroU32::new(100), + max_concurrent_handshakes: NonZeroU32::new(10), + handshake_timeout: NonZeroU64::new(10000), + }); + + let enclave_runtime = EnclaveRuntime { + data_plane_version: "0.0.0".to_string(), + installer_version: "abcdef".to_string(), + }; + + let processed_file = + process_dockerfile(&config, &mut readable_contents, &enclave_runtime, false) + .await + .unwrap(); + + let dataplane_directive = processed_file + .iter() + .map(|directive| directive.to_string()) + .find(|directive| directive.contains("/etc/dataplane-config.json")) + .expect("dataplane-config directive present"); + + let expected = r#"RUN echo {\"acceptor\":{\"handshake_timeout\":{\"nanos\":0,\"secs\":10},\"max_concurrent_connections\":100,\"max_concurrent_handshakes\":10},\"api_key_auth\":true,\"attestation_cors\":{\"origin\":\"test.com\"},\"forward_proxy_protocol\":false,\"trusted_headers\":[\"X-Evervault-*\"],\"trx_logging_enabled\":true} > /etc/dataplane-config.json"#; + assert_eq!(dataplane_directive, expected); + } + + #[tokio::test] + async fn test_process_dockerfile_with_sub_second_handshake_timeout() { + let sample_dockerfile_contents = r#"FROM alpine +RUN touch /hello-script;\ + /bin/sh -c "echo -e '"'#!/bin/sh\nwhile true; do echo "hello"; sleep 2; done;\n'"' > /hello-script" +ENTRYPOINT ["sh", "/hello-script"]"#; + let mut readable_contents = sample_dockerfile_contents.as_bytes(); + + let mut config = get_config(false); + config.acceptor = Some(crate::config::AcceptorConfig { + max_concurrent_connections: None, + max_concurrent_handshakes: None, + handshake_timeout: std::num::NonZeroU64::new(500), + }); + + let enclave_runtime = EnclaveRuntime { + data_plane_version: "0.0.0".to_string(), + installer_version: "abcdef".to_string(), + }; + + let processed_file = + process_dockerfile(&config, &mut readable_contents, &enclave_runtime, false) + .await + .unwrap(); + + let dataplane_directive = processed_file + .iter() + .map(|directive| directive.to_string()) + .find(|directive| directive.contains("/etc/dataplane-config.json")) + .expect("dataplane-config directive present"); + + assert!(dataplane_directive + .contains(r#"\"acceptor\":{\"handshake_timeout\":{\"nanos\":500000000,\"secs\":0}}"#)); + } + #[tokio::test] #[serial_test::serial] async fn test_choose_output_dir() { diff --git a/crates/ev-enclave/src/config.rs b/crates/ev-enclave/src/config.rs index 334d36f6..78ff3a2d 100644 --- a/crates/ev-enclave/src/config.rs +++ b/crates/ev-enclave/src/config.rs @@ -1,3 +1,4 @@ +use std::num::{NonZeroU32, NonZeroU64}; use std::path::Path; use crate::cert::{get_cert_validity_period, CertValidityPeriod}; @@ -75,6 +76,33 @@ impl ScalingSettings { } } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AcceptorConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub max_concurrent_connections: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_concurrent_handshakes: Option, + /// Handshake timeout in milliseconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub handshake_timeout: Option, +} + +impl AcceptorConfig { + pub fn validate(&self) -> Result<(), EnclaveConfigError> { + if let (Some(connections), Some(handshakes)) = ( + self.max_concurrent_connections, + self.max_concurrent_handshakes, + ) { + if handshakes > connections { + return Err(EnclaveConfigError::InvalidAcceptorConfig(format!( + "max_concurrent_handshakes ({handshakes}) must not exceed max_concurrent_connections ({connections})" + ))); + } + } + Ok(()) + } +} + #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct SigningInfo { #[serde(rename = "certPath")] @@ -212,6 +240,8 @@ pub enum EnclaveConfigError { MissingField(String), #[error("TLS Termination must be enabled to enable Enclave logging.")] LoggingEnabledWithoutTLSTermination(), + #[error("Invalid acceptor config — {0}")] + InvalidAcceptorConfig(String), } impl CliError for EnclaveConfigError { @@ -221,7 +251,8 @@ impl CliError for EnclaveConfigError { Self::FailedToParseEnclaveConfig(_) | Self::MissingDockerfile | Self::MissingField(_) - | Self::LoggingEnabledWithoutTLSTermination() => exitcode::DATAERR, + | Self::LoggingEnabledWithoutTLSTermination() + | Self::InvalidAcceptorConfig(_) => exitcode::DATAERR, Self::MissingSigningInfo(signing_err) => signing_err.exitcode(), } } @@ -300,6 +331,8 @@ pub struct EnclaveConfig { pub signing: Option, pub attestation_cors: Option, pub attestation: Option, + #[serde(default)] + pub acceptor: Option, } // This type exists only to read V0 tomls and migrate to V1 @@ -350,6 +383,7 @@ impl std::convert::From for EnclaveConfig { signing: value.signing, attestation: value.attestation, attestation_cors: value.attestation_cors, + acceptor: None, } } } @@ -390,6 +424,7 @@ pub struct ValidatedEnclaveBuildConfig { pub trusted_headers: Vec, pub healthcheck: Option, pub attestation_cors: Option, + pub acceptor: Option, } impl ValidatedEnclaveBuildConfig { @@ -475,6 +510,10 @@ impl ValidatedEnclaveBuildConfig { pub fn attestation_cors(&self) -> &Option { &self.attestation_cors } + + pub fn acceptor(&self) -> Option<&AcceptorConfig> { + self.acceptor.as_ref() + } } impl EnclaveConfig { @@ -597,6 +636,10 @@ impl std::convert::TryFrom<&EnclaveConfig> for ValidatedEnclaveBuildConfig { let scaling_settings = config.scaling.clone(); + if let Some(acceptor) = config.acceptor.as_ref() { + acceptor.validate()?; + } + Ok(ValidatedEnclaveBuildConfig { version: config.version, enclave_uuid, @@ -617,6 +660,7 @@ impl std::convert::TryFrom<&EnclaveConfig> for ValidatedEnclaveBuildConfig { trusted_headers: config.trusted_headers.clone(), healthcheck: config.healthcheck.clone(), attestation_cors: config.attestation_cors.clone(), + acceptor: config.acceptor.clone(), }) } } @@ -671,7 +715,8 @@ pub fn read_and_validate_config( mod test { use common::enclave::types::AttestationCors; - use super::{BuildTimeConfig, EnclaveConfig}; + use super::{AcceptorConfig, BuildTimeConfig, EnclaveConfig, ValidatedEnclaveBuildConfig}; + use std::num::{NonZeroU32, NonZeroU64}; struct ExampleArgs { cert: String, @@ -722,6 +767,7 @@ mod test { "/health".to_string(), )), attestation_cors: None, + acceptor: None, }; let test_args = ExampleArgs { @@ -769,6 +815,7 @@ mod test { attestation_cors: Some(AttestationCors { origin: "*".to_string(), }), + acceptor: None, }; let test_args = ExampleArgs { @@ -787,4 +834,102 @@ mod test { config.healthcheck.clone().unwrap() ); } + + #[test] + fn parse_config_with_acceptor_table() { + let toml = r#"version = 1 +name = "Enclave123" +uuid = "abcdef123" +app_uuid = "abcdef321" +team_uuid = "team_abcdef456" +debug = false +dockerfile = "./Dockerfile" + +[egress] +enabled = false + +[signing] +certPath = "../../fixtures/cert.pem" +keyPath = "../../fixtures/key.pem" + +[acceptor] +max_concurrent_connections = 100 +max_concurrent_handshakes = 10 +handshake_timeout = 10000 +"#; + let config: EnclaveConfig = toml::de::from_str(toml).unwrap(); + let acceptor = config.acceptor.as_ref().expect("acceptor table present"); + assert_eq!(acceptor.max_concurrent_connections, NonZeroU32::new(100)); + assert_eq!(acceptor.max_concurrent_handshakes, NonZeroU32::new(10)); + assert_eq!(acceptor.handshake_timeout, NonZeroU64::new(10000)); + + let validated: ValidatedEnclaveBuildConfig = (&config).try_into().unwrap(); + let acceptor = validated.acceptor().expect("acceptor threaded through"); + assert_eq!(acceptor.max_concurrent_connections, NonZeroU32::new(100)); + assert_eq!(acceptor.max_concurrent_handshakes, NonZeroU32::new(10)); + assert_eq!(acceptor.handshake_timeout, NonZeroU64::new(10000)); + } + + #[test] + fn parse_config_without_acceptor_table() { + let toml = r#"version = 1 +name = "Enclave123" +uuid = "abcdef123" +app_uuid = "abcdef321" +team_uuid = "team_abcdef456" +debug = false +dockerfile = "./Dockerfile" + +[egress] +enabled = false + +[signing] +certPath = "./cert.pem" +keyPath = "./key.pem" +"#; + let config: EnclaveConfig = toml::de::from_str(toml).unwrap(); + assert!(config.acceptor.is_none()); + } + + #[test] + fn acceptor_config_rejects_zero_values_on_deserialization() { + for field in [ + "max_concurrent_connections", + "max_concurrent_handshakes", + "handshake_timeout", + ] { + let toml = format!("{field} = 0"); + assert!( + toml::de::from_str::(&toml).is_err(), + "expected {field} = 0 to be rejected" + ); + } + } + + #[test] + fn acceptor_validate_rejects_handshakes_exceeding_connections() { + let acceptor = AcceptorConfig { + max_concurrent_connections: NonZeroU32::new(10), + max_concurrent_handshakes: NonZeroU32::new(11), + handshake_timeout: None, + }; + assert!(acceptor.validate().is_err()); + } + + #[test] + fn acceptor_validate_accepts_valid_combinations() { + let acceptor = AcceptorConfig { + max_concurrent_connections: NonZeroU32::new(100), + max_concurrent_handshakes: NonZeroU32::new(100), + handshake_timeout: NonZeroU64::new(5000), + }; + assert!(acceptor.validate().is_ok()); + + let partial = AcceptorConfig { + max_concurrent_connections: None, + max_concurrent_handshakes: NonZeroU32::new(5), + handshake_timeout: None, + }; + assert!(partial.validate().is_ok()); + } }