diff --git a/oak_attestation_gcp/src/policy.rs b/oak_attestation_gcp/src/policy.rs index 4d5a87a34e8..a2decadd988 100644 --- a/oak_attestation_gcp/src/policy.rs +++ b/oak_attestation_gcp/src/policy.rs @@ -212,9 +212,20 @@ impl ConfidentialSpacePolicy { workload_endorsement, ref_value, ), - None => { - Err(ConfidentialSpaceVerificationError::MissingWorkloadEndorsementError) - } + None => match ref_value.r#type { + Some(binary_reference_value::Type::Digests(_)) + | Some(binary_reference_value::Type::Skip(_)) => { + verify_endorsement_wrapper( + verification_time, + &image_reference, + &SignedEndorsement::default(), + ref_value, + ) + } + _ => Err( + ConfidentialSpaceVerificationError::MissingWorkloadEndorsementError, + ), + }, } } WorkloadReferenceValues::ContainerImageReferencePrefix(container_image_reference_prefix) => { diff --git a/oak_proxy/README.md b/oak_proxy/README.md index 3283c43069e..ac9757da3d1 100644 --- a/oak_proxy/README.md +++ b/oak_proxy/README.md @@ -207,6 +207,12 @@ configuration file passed via the `--config` command-line argument. - `server_proxy_url`: The WebSocket `Url` of the server proxy. +### Client CLI Flags + +- `--http-error-on-fail`: (Optional, Boolean) If set, the client proxy returns an **HTTP 502 Bad Gateway** response to the connected application if the handshake or attestation with the server proxy fails. + - **Default**: `false` (Connection is dropped/reset silently on failure). + - **Use Case**: Useful when the client application is an HTTP user agent (like a browser or `curl`) that can render a helpful error message instead of a generic connection error. + ### Server-Specific Options - `backend_address`: The `SocketAddr` of the final backend application where @@ -238,6 +244,7 @@ Confidential Space attestations. **`server.toml`** + ```toml listen_address = "127.0.0.1:8081" backend_address = "127.0.0.1:8080" @@ -249,9 +256,57 @@ type = "confidential_space" # Verify the client's Confidential Space attestation. [[attestation_verifiers]] type = "confidential_space" -root_certificate_pem_path = "/path/to/gcp_root.pem" +# Download from https://confidentialcomputing.googleapis.com/.well-known/confidential_space_root.crt +root_certificate_pem_path = "/path/to/confidential_space_root.crt" +# Optional: Restrict allowed container images +# expected_image_digests = ["sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"] +# Optional: Use a signed policy for allowed images +# signed_policy_path = "/path/to/policy.json" +# policy_signature_path = "/path/to/policy.sig" +# policy_public_key_pem_path = "/path/to/key.pem" +``` + +### Signed Policy Format + +If using `signed_policy_path`, the policy file must be a valid **in-toto Statement** (v1). + +Example `policy.json`: + +```json +{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "gcr.io/my-project/my-image", + "digest": { + "sha256": "cc564c5f64a18fc6e53dd737ec19774b68b609eb59cac4f13dcfd25cb61f3f68" + } + }, + { + "name": "gcr.io/my-project/other-image", + "digest": { + "sha256": "33e3d4a3a7b79ec43c89803c1dbb1e15317b72f78e9e2ed86d8ff9c1d1431380" + } + } + ], + "predicateType": "https://example.com/custom/v1", + "predicate": {} +} ``` +**Schema Details:** + +- **`_type`**: Required. Must be `https://in-toto.io/Statement/v1`. +- **`subject`**: Required. A list of artifacts covered by this policy. + - **`name`**: (Optional) Human-readable name or URI of the artifact (e.g., image URL). + - **`digest`**: Required. A map of cryptographic digests. + - **`sha256`**: Required. The SHA-256 digest of the container image (hex-encoded). +- **`predicateType`**: Required. URI identifying the policy type (e.g., `https://example.com/custom/v1`). +- **`predicate`**: (Optional) Additional policy data (ignored by `oak_proxy` for digest verification). + +This format follows the [in-toto Statement Specification](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md). + + **`client.toml`** ```toml @@ -265,7 +320,8 @@ type = "confidential_space" # Verify the server's Confidential Space attestation. [[attestation_verifiers]] type = "confidential_space" -root_certificate_pem_path = "/path/to/gcp_root.pem" +# Download from https://confidentialcomputing.googleapis.com/.well-known/confidential_space_root.crt +root_certificate_pem_path = "/path/to/confidential_space_root.crt" ``` ## Extending Attestation diff --git a/oak_proxy/client/src/main.rs b/oak_proxy/client/src/main.rs index 4a41f2dc85d..cf20e575d25 100644 --- a/oak_proxy/client/src/main.rs +++ b/oak_proxy/client/src/main.rs @@ -24,7 +24,10 @@ use oak_proxy_lib::{ websocket::{read_message, write_message}, }; use oak_session::{ClientSession, ProtocolEngine, Session}; -use tokio::net::{TcpListener, TcpStream}; +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, +}; use url::Url; #[derive(Parser, Debug)] @@ -43,13 +46,19 @@ struct Args { /// This will override the value in the config file. #[arg(long, env = "OAK_PROXY_SERVER_URL")] server_proxy_url: Option, + + /// If set, returns an HTTP 502 Bad Gateway response with error details to the client when the + /// upstream handshake or attestation fails, instead of silently dropping the connection. + /// Useful when the client is an HTTP user agent. + #[arg(long)] + http_error_on_fail: bool, } #[tokio::main] async fn main() -> anyhow::Result<()> { env_logger::init(); - let Args { mut config, listen_address, server_proxy_url } = Args::parse(); + let Args { mut config, listen_address, server_proxy_url, http_error_on_fail } = Args::parse(); // The command-line arguments override the values from the config file. if let Some(listen_address) = listen_address { @@ -72,43 +81,78 @@ async fn main() -> anyhow::Result<()> { log::info!("[Client] Accepted connection from {}", peer_address); let config = config.clone(); tokio::spawn(async move { - if let Err(err) = handle_connection(stream, &config).await { + if let Err(err) = handle_connection(stream, &config, http_error_on_fail).await { log::error!("[Client] Error handling connection: {:?}", err); } }); } } -async fn handle_connection(app_stream: TcpStream, config: &ClientConfig) -> anyhow::Result<()> { - let server_proxy_url = - config.server_proxy_url.as_ref().context("server_proxy_url wasn't set")?; - let (mut server_proxy_stream, _) = tokio_tungstenite::connect_async(server_proxy_url).await?; - log::info!("[Client] Connected to server proxy at {}", server_proxy_url); - - let client_config = config::build_session_config( - &config.attestation_generators, - &config.attestation_verifiers, - )?; - let mut session = ClientSession::create(client_config)?; - - // Handshake - while !session.is_open() { - if let Some(request) = session.get_outgoing_message()? { - write_message(&mut server_proxy_stream, &request).await?; - } +async fn handle_connection( + mut app_stream: TcpStream, + config: &ClientConfig, + http_error_on_fail: bool, +) -> anyhow::Result<()> { + // Connection and Handshake + let setup_result = async { + let server_proxy_url = + config.server_proxy_url.as_ref().context("server_proxy_url wasn't set")?; + let (mut server_proxy_stream, _) = + tokio_tungstenite::connect_async(server_proxy_url).await?; + log::info!("[Client] Connected to server proxy at {}", server_proxy_url); + + let client_config = config::build_session_config( + &config.attestation_generators, + &config.attestation_verifiers, + )?; + let mut session = ClientSession::create(client_config)?; + + while !session.is_open() { + if let Some(request) = session.get_outgoing_message()? { + write_message(&mut server_proxy_stream, &request).await?; + } - if !session.is_open() { - let response = read_message(&mut server_proxy_stream).await?; - session.put_incoming_message(response)?; + if !session.is_open() { + let response = read_message(&mut server_proxy_stream).await?; + session.put_incoming_message(response)?; + } + } + Ok((session, server_proxy_stream)) + } + .await; + + match setup_result { + Ok((session, server_proxy_stream)) => { + log::info!("[Client] Oak Session established with server proxy."); + proxy::< + ClientSession, + oak_proto_rust::oak::session::v1::SessionResponse, + oak_proto_rust::oak::session::v1::SessionRequest, + >( + PeerRole::Client, + session, + app_stream, + server_proxy_stream, + config.keep_alive_interval, + ) + .await + } + Err(err) => { + if http_error_on_fail { + let error_msg = format!("Attestation/Handshake Failed: {:#}", err); + let body = format!("[Oak-Proxy] {}", error_msg); + let response = format!( + "HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + if let Err(write_err) = app_stream.write_all(response.as_bytes()).await { + log::warn!("Failed to write HTTP error response: {:?}", write_err); + } + let _ = app_stream.flush().await; + let _ = app_stream.shutdown().await; + } + Err(err) } } - - log::info!("[Client] Oak Session established with server proxy."); - - proxy::< - ClientSession, - oak_proto_rust::oak::session::v1::SessionResponse, - oak_proto_rust::oak::session::v1::SessionRequest, - >(PeerRole::Client, session, app_stream, server_proxy_stream, config.keep_alive_interval) - .await } diff --git a/oak_proxy/lib/BUILD b/oak_proxy/lib/BUILD index 1b4b1921a7c..1ffa65b4115 100644 --- a/oak_proxy/lib/BUILD +++ b/oak_proxy/lib/BUILD @@ -23,7 +23,7 @@ package( rust_library( name = "oak_proxy_lib", - srcs = glob(["src/**/*.rs"]), + srcs = glob(["src/**/*.rs"], exclude = ["src/tests.rs"]), deps = [ "//oak_attestation", "//oak_attestation_gcp", @@ -45,6 +45,7 @@ rust_library( "@oak_crates_index//:sha2", "@oak_crates_index//:tokio", "@oak_crates_index//:tokio-tungstenite", + "@oak_crates_index//:serde_json", "@oak_crates_index//:toml", "@oak_crates_index//:tungstenite", "@oak_crates_index//:url", @@ -63,3 +64,12 @@ rust_test( "@oak_crates_index//:toml", ], ) + +rust_test( + name = "oak_proxy_lib_unit_tests", + crate = ":oak_proxy_lib", + deps = [ + "@oak_crates_index//:rand", + "@oak_crates_index//:tempfile", + ], +) diff --git a/oak_proxy/lib/src/config/confidential_space.rs b/oak_proxy/lib/src/config/confidential_space.rs index 2fd12077c7c..a87b27609fb 100644 --- a/oak_proxy/lib/src/config/confidential_space.rs +++ b/oak_proxy/lib/src/config/confidential_space.rs @@ -16,6 +16,7 @@ use std::sync::Arc; +use anyhow::Context; use oak_attestation::public_key::{PublicKeyAttester, PublicKeyEndorser}; use oak_attestation_gcp::{ attestation::request_attestation_token, @@ -25,7 +26,13 @@ use oak_attestation_gcp::{ use oak_attestation_verification::EventLogVerifier; use oak_proto_rust::{ attestation::CONFIDENTIAL_SPACE_ATTESTATION_ID, - oak::attestation::v1::{ConfidentialSpaceEndorsement, ConfidentialSpaceReferenceValues}, + oak::{ + attestation::v1::{ + binary_reference_value, confidential_space_reference_values, BinaryReferenceValue, + ConfidentialSpaceEndorsement, ConfidentialSpaceReferenceValues, Digests, + }, + RawDigest as CommonRawDigest, + }, }; use oak_session::{ config::SessionConfigBuilder, key_extractor::DefaultBindingKeyExtractor, @@ -75,17 +82,67 @@ impl ConfidentialSpaceGeneratorParams { #[derive(Deserialize, Serialize, Debug, Clone)] pub struct ConfidentialSpaceVerifierParams { pub root_certificate_pem_path: String, + pub expected_image_digests: Option>, + pub signed_policy_path: Option, + pub policy_signature_path: Option, + pub policy_public_key_pem_path: Option, } impl ConfidentialSpaceVerifierParams { + fn get_allowed_digests(&self) -> anyhow::Result>> { + let mut allowed_digests = Vec::new(); + + if let Some(digests) = &self.expected_image_digests { + allowed_digests.extend(digests.clone()); + } + + if let (Some(policy_path), Some(sig_path), Some(key_path)) = ( + &self.signed_policy_path, + &self.policy_signature_path, + &self.policy_public_key_pem_path, + ) { + let policy = crate::config::signed_policy::SignedPolicy::load( + std::path::Path::new(policy_path), + std::path::Path::new(sig_path), + std::path::Path::new(key_path), + )?; + allowed_digests.extend(policy.allowed_digests); + } + + allowed_digests + .into_iter() + .map(|hex_digest| hex::decode(hex_digest).context("decoding hex digest")) + .collect() + } + pub fn apply(&self, builder: SessionConfigBuilder) -> anyhow::Result { let root_pem = std::fs::read_to_string(&self.root_certificate_pem_path) .expect("could not read root certificate"); - let reference_values = ConfidentialSpaceReferenceValues { - root_certificate_pem: root_pem, - r#container_image: None, + let allowed_digests = self.get_allowed_digests()?; + + let container_image = if allowed_digests.is_empty() { + None + } else { + let raw_digests: Vec = allowed_digests + .into_iter() + .map(|digest| CommonRawDigest { sha2_256: digest, ..Default::default() }) + .collect(); + + let digests = Digests { + #[allow(deprecated)] + digests: raw_digests, + }; + let binary_reference_value = BinaryReferenceValue { + r#type: Some(binary_reference_value::Type::Digests(digests)), + }; + Some(confidential_space_reference_values::ContainerImage::ImageReferenceValue( + binary_reference_value, + )) }; + + let reference_values = + ConfidentialSpaceReferenceValues { root_certificate_pem: root_pem, container_image }; let policy = confidential_space_policy_from_reference_values(&reference_values)?; let attestation_verifier = EventLogVerifier::new( vec![Box::new(policy)], @@ -100,3 +157,7 @@ impl ConfidentialSpaceVerifierParams { )) } } + +#[cfg(test)] +#[path = "confidential_space_tests.rs"] +mod tests; diff --git a/oak_proxy/lib/src/config/confidential_space_tests.rs b/oak_proxy/lib/src/config/confidential_space_tests.rs new file mode 100644 index 00000000000..ce0e05a9936 --- /dev/null +++ b/oak_proxy/lib/src/config/confidential_space_tests.rs @@ -0,0 +1,98 @@ +// +// Copyright 2025 The Project Oak Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +use p256::{ + ecdsa::{signature::Signer, Signature, SigningKey}, + pkcs8::EncodePublicKey, +}; +use tempfile::tempdir; + +use crate::config::confidential_space::ConfidentialSpaceVerifierParams; + +#[test] +fn test_get_allowed_digests_with_manual_list() { + let dir = tempdir().unwrap(); + let root_cert_path = dir.path().join("root.pem"); + // We don't need to write the file content if we don't call apply(), + // but the struct fields need to be valid strings. + + let params = ConfidentialSpaceVerifierParams { + root_certificate_pem_path: root_cert_path.to_str().unwrap().to_string(), + expected_image_digests: Some(vec![hex::encode(b"digest1"), hex::encode(b"digest2")]), + signed_policy_path: None, + policy_signature_path: None, + policy_public_key_pem_path: None, + }; + + let digests = params.get_allowed_digests().expect("failed to get digests"); + assert_eq!(digests.len(), 2); + // Digests are stored as Vec (decoded from hex) in get_allowed_digests + // return + assert!(digests.contains(&b"digest1".to_vec())); + assert!(digests.contains(&b"digest2".to_vec())); +} + +#[test] +fn test_get_allowed_digests_merging() { + let dir = tempdir().unwrap(); + + // Setup Policy + let mut rng = rand::thread_rng(); + let signing_key = SigningKey::random(&mut rng); + let verifying_key = signing_key.verifying_key(); + let public_key_pem = verifying_key.to_public_key_pem(Default::default()).unwrap(); + + let policy_path = dir.path().join("policy.json"); + let sig_path = dir.path().join("policy.sig"); + let key_path = dir.path().join("key.pem"); + + let policy_json = format!( + r#"{{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + {{ + "name": "test", + "digest": {{ + "sha256": "{}" + }} + }} + ], + "predicateType": "custom", + "predicate": {{}} + }}"#, + hex::encode(b"policy_digest") + ); + + std::fs::write(&policy_path, policy_json.as_bytes()).unwrap(); + std::fs::write(&key_path, public_key_pem).unwrap(); + + let signature: Signature = signing_key.sign(policy_json.as_bytes()); + std::fs::write(&sig_path, signature.to_der()).unwrap(); + + // Setup Params + let params = ConfidentialSpaceVerifierParams { + root_certificate_pem_path: "dummy.pem".to_string(), + expected_image_digests: Some(vec![hex::encode(b"manual_digest")]), + signed_policy_path: Some(policy_path.to_str().unwrap().to_string()), + policy_signature_path: Some(sig_path.to_str().unwrap().to_string()), + policy_public_key_pem_path: Some(key_path.to_str().unwrap().to_string()), + }; + + let digests = params.get_allowed_digests().expect("failed to get digests"); + assert_eq!(digests.len(), 2); + assert!(digests.contains(&b"manual_digest".to_vec())); + assert!(digests.contains(&b"policy_digest".to_vec())); +} diff --git a/oak_proxy/lib/src/config/mod.rs b/oak_proxy/lib/src/config/mod.rs index d678ed4d8a2..1714768fff9 100644 --- a/oak_proxy/lib/src/config/mod.rs +++ b/oak_proxy/lib/src/config/mod.rs @@ -15,6 +15,7 @@ // pub mod confidential_space; +pub mod signed_policy; use std::{net::SocketAddr, time::Duration}; diff --git a/oak_proxy/lib/src/config/signed_policy.rs b/oak_proxy/lib/src/config/signed_policy.rs new file mode 100644 index 00000000000..d98f877c56d --- /dev/null +++ b/oak_proxy/lib/src/config/signed_policy.rs @@ -0,0 +1,161 @@ +// +// Copyright 2025 The Project Oak Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +use std::{collections::HashMap, path::Path}; + +use anyhow::Context; +use p256::{ + ecdsa::{signature::Verifier, Signature, VerifyingKey}, + pkcs8::DecodePublicKey, +}; +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +struct InTotoStatement { + #[serde(rename = "subject")] + subjects: Vec, +} + +#[derive(Deserialize, Debug)] +struct InTotoSubject { + digest: HashMap, +} + +pub struct SignedPolicy { + pub allowed_digests: Vec, +} + +impl SignedPolicy { + pub fn load( + policy_path: &Path, + signature_path: &Path, + public_key_pem_path: &Path, + ) -> anyhow::Result { + let policy_bytes = std::fs::read(policy_path).context("reading policy file")?; + let signature_bytes = std::fs::read(signature_path).context("reading signature file")?; + let public_key_pem = + std::fs::read_to_string(public_key_pem_path).context("reading public key pem")?; + + Self::verify_and_parse(&policy_bytes, &signature_bytes, &public_key_pem) + } + + fn verify_and_parse( + policy_bytes: &[u8], + signature_bytes: &[u8], + public_key_pem: &str, + ) -> anyhow::Result { + // 1. Verify Signature + Self::verify_signature(policy_bytes, signature_bytes, public_key_pem) + .context("verifying policy signature")?; + + // 2. Parse Policy + let statement: InTotoStatement = + serde_json::from_slice(policy_bytes).context("parsing in-toto statement")?; + + // 3. Extract Digests + let mut allowed_digests = Vec::new(); + for subject in statement.subjects { + if let Some(sha256) = subject.digest.get("sha256") { + allowed_digests.push(sha256.clone()); + } + } + + Ok(Self { allowed_digests }) + } + + fn verify_signature( + message: &[u8], + signature_bytes: &[u8], + public_key_pem: &str, + ) -> anyhow::Result<()> { + let verifying_key = VerifyingKey::from_public_key_pem(public_key_pem) + .map_err(|e| anyhow::anyhow!("failed to parse public key pem: {}", e))?; + + let signature = Signature::from_der(signature_bytes) + .map_err(|e| anyhow::anyhow!("invalid DER signature: {}", e))?; + + verifying_key + .verify(message, &signature) + .map_err(|e| anyhow::anyhow!("signature verification failed: {}", e))?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use p256::{ + ecdsa::{signature::Signer, SigningKey}, + pkcs8::EncodePublicKey, + }; + + use super::*; + + #[test] + fn test_verify_and_parse_valid() { + let mut rng = rand::thread_rng(); + let signing_key = SigningKey::random(&mut rng); + let verifying_key = signing_key.verifying_key(); + let public_key_pem = verifying_key.to_public_key_pem(Default::default()).unwrap(); + + let policy_json = r#"{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "test", + "digest": { + "sha256": "aabbcc" + } + } + ], + "predicateType": "custom", + "predicate": {} + }"#; + let policy_bytes = policy_json.as_bytes(); + let signature: Signature = signing_key.sign(policy_bytes); + let signature_bytes = signature.to_der(); + + let policy = SignedPolicy::verify_and_parse( + policy_bytes, + signature_bytes.as_bytes(), + &public_key_pem, + ) + .expect("failed to verify and parse"); + + assert_eq!(policy.allowed_digests, vec!["aabbcc"]); + } + + #[test] + fn test_verify_invalid_signature() { + let mut rng = rand::thread_rng(); + let signing_key = SigningKey::random(&mut rng); + let verifying_key = signing_key.verifying_key(); + let public_key_pem = verifying_key.to_public_key_pem(Default::default()).unwrap(); + + let policy_json = r#"{}"#; + let policy_bytes = policy_json.as_bytes(); + // Sign different data + let signature: Signature = signing_key.sign(b"different data"); + let signature_bytes = signature.to_der(); + + let result = SignedPolicy::verify_and_parse( + policy_bytes, + signature_bytes.as_bytes(), + &public_key_pem, + ); + assert!(result.is_err()); + } +} diff --git a/oak_proxy/lib/src/lib.rs b/oak_proxy/lib/src/lib.rs index dbeeb272bf1..f6377114e76 100644 --- a/oak_proxy/lib/src/lib.rs +++ b/oak_proxy/lib/src/lib.rs @@ -16,6 +16,4 @@ pub mod config; pub mod proxy; -#[cfg(test)] -pub mod tests; pub mod websocket; diff --git a/oak_proxy/tests/integration_test.rs b/oak_proxy/tests/integration_test.rs index ea9d992da7e..48bfb6f1296 100644 --- a/oak_proxy/tests/integration_test.rs +++ b/oak_proxy/tests/integration_test.rs @@ -107,3 +107,56 @@ async fn proxy_test() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test] +async fn http_failure_test() -> anyhow::Result<()> { + let client_port = find_free_port(); + let server_proxy_port = find_free_port(); // We won't start a server here + + let client_config = ClientConfig { + listen_address: Some(format!("127.0.0.1:{}", client_port).parse()?), + server_proxy_url: Some(format!("ws://127.0.0.1:{}", server_proxy_port).parse()?), + attestation_generators: Vec::new(), + attestation_verifiers: Vec::new(), + keep_alive_interval: Duration::from_secs(10), + }; + + let config_path = format!("client_http_fail_{}.toml", client_port); + std::fs::write(&config_path, toml::to_string(&client_config)?)?; + + let mut client_proxy = Command::new("oak_proxy/client/client") + .args([ + "--config", + &config_path, + "--listen-address", + &client_config.listen_address.unwrap().to_string(), + "--server-proxy-url", + client_config.server_proxy_url.unwrap().as_ref(), + "--http-error-on-fail", + ]) + .env("RUST_LOG", "debug") + .spawn()?; + + // Wait for process to start + tokio::time::sleep(Duration::from_secs(1)).await; + + // Connect and send simple HTTP request + let connect_result = async { + let mut stream = TcpStream::connect(format!("127.0.0.1:{}", client_port)).await?; + stream.write_all(b"GET / HTTP/1.1\r\n\r\n").await?; + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).await?; + Ok::, anyhow::Error>(buf) + }.await; + + // Cleanup first to ensure we don't leave processes running if assert fails + let _ = client_proxy.kill(); + let _ = std::fs::remove_file(config_path); + + let buf = connect_result?; + let response = String::from_utf8_lossy(&buf); + println!("Response: {}", response); + assert!(response.contains("HTTP/1.1 502 Bad Gateway")); + + Ok(()) +}