mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-25 06:42:52 +08:00
Extract four categories of copy-pasted test code into single canonical locations: - POLICY_OBJECT_TYPE / DRAFT_CHUNK_OBJECT_TYPE constants that were defined identically in both persistence/sqlite.rs and persistence/postgres.rs are now owned by persistence/mod.rs. - test_server_state() was duplicated across grpc/provider.rs, grpc/policy.rs, and grpc/sandbox.rs. A shared grpc::test_support module in grpc/mod.rs now owns the single implementation; each submodule imports it. - The TestOpenShell stub (full OpenShell trait impl), install_rustls_provider(), PkiBundle, generate_pki(), and start_test_server() were copied across up to five openshell-server integration test files. A new tests/common/mod.rs module owns them; each test file uses mod common. - StubResponse, unique_socket_path(), and spawn_podman_stub() were duplicated between openshell-driver-podman/src/driver.rs and openshell-driver-podman/src/grpc.rs. A new src/test_utils.rs (cfg(test)-gated) owns the shared helpers.
415 lines
14 KiB
Rust
415 lines
14 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Integration tests for edge tunnel auth compatibility.
|
|
//!
|
|
//! These tests verify that the gateway can operate in "dual-auth" mode where
|
|
//! the TLS layer accepts connections both with and without client certificates.
|
|
//! This is the foundation for edge-authenticated tunnel support: the edge proxy
|
|
//! terminates TLS and re-originates a new connection to the gateway without a
|
|
//! client cert. The gateway must accept these connections and defer auth to the
|
|
//! application layer (e.g. a bearer JWT header).
|
|
//!
|
|
//! Test matrix:
|
|
//!
|
|
//! | `client_ca` | client cert | bearer header | expected |
|
|
//! |-------------|-------------|---------------|---------------------------|
|
|
//! | Some | valid | — | OK (cert validated) |
|
|
//! | Some | none | — | OK (cert optional) |
|
|
//! | Some | none | present | OK (bearer auth) |
|
|
//! | Some | rogue CA | — | rejected (bad cert) |
|
|
//! | None | none | — | OK (HTTPS-only) |
|
|
//!
|
|
//! Client certificates are always optional when a CA is configured. They are
|
|
//! validated when present (rogue-CA certs are rejected) but never required.
|
|
//! Authentication is handled at the application layer (OIDC bearer tokens).
|
|
|
|
mod common;
|
|
|
|
use bytes::Bytes;
|
|
use common::{PkiBundle, generate_pki, install_rustls_provider, start_test_server};
|
|
use http_body_util::Empty;
|
|
use hyper::{Request, StatusCode};
|
|
use hyper_rustls::HttpsConnectorBuilder;
|
|
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
|
|
use openshell_core::proto::{HealthRequest, ServiceStatus, open_shell_client::OpenShellClient};
|
|
use openshell_server::TlsAcceptor;
|
|
use rcgen::{CertificateParams, IsCa, KeyPair};
|
|
use rustls::RootCertStore;
|
|
use rustls::pki_types::CertificateDer;
|
|
use rustls_pemfile::certs;
|
|
use tonic::Status;
|
|
use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Client helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Build a gRPC client with mTLS (CA + client cert).
|
|
async fn grpc_client_mtls(
|
|
addr: std::net::SocketAddr,
|
|
ca_pem: Vec<u8>,
|
|
client_cert_pem: Vec<u8>,
|
|
client_key_pem: Vec<u8>,
|
|
) -> OpenShellClient<Channel> {
|
|
let ca_cert = tonic::transport::Certificate::from_pem(ca_pem);
|
|
let identity = tonic::transport::Identity::from_pem(client_cert_pem, client_key_pem);
|
|
let tls = ClientTlsConfig::new()
|
|
.ca_certificate(ca_cert)
|
|
.identity(identity)
|
|
.domain_name("localhost");
|
|
let endpoint = Endpoint::from_shared(format!("https://localhost:{}", addr.port()))
|
|
.expect("invalid endpoint")
|
|
.tls_config(tls)
|
|
.expect("failed to set tls");
|
|
let channel = endpoint.connect().await.expect("failed to connect");
|
|
OpenShellClient::new(channel)
|
|
}
|
|
|
|
/// Build a gRPC client *without* a client cert (simulates Cloudflare tunnel).
|
|
async fn grpc_client_no_cert(
|
|
addr: std::net::SocketAddr,
|
|
ca_pem: Vec<u8>,
|
|
) -> OpenShellClient<Channel> {
|
|
let ca_cert = tonic::transport::Certificate::from_pem(ca_pem);
|
|
let tls = ClientTlsConfig::new()
|
|
.ca_certificate(ca_cert)
|
|
.domain_name("localhost");
|
|
let endpoint = Endpoint::from_shared(format!("https://localhost:{}", addr.port()))
|
|
.expect("invalid endpoint")
|
|
.tls_config(tls)
|
|
.expect("failed to set tls");
|
|
let channel = endpoint.connect().await.expect("failed to connect");
|
|
OpenShellClient::new(channel)
|
|
}
|
|
|
|
/// Build a gRPC client without a client cert, adding a `cf-authorization`
|
|
/// metadata header to every request (simulates the steady-state tunnel flow).
|
|
async fn grpc_client_with_cf_header(
|
|
addr: std::net::SocketAddr,
|
|
ca_pem: Vec<u8>,
|
|
token: &str,
|
|
) -> OpenShellClient<tonic::service::interceptor::InterceptedService<Channel, CfInterceptor>> {
|
|
let ca_cert = tonic::transport::Certificate::from_pem(ca_pem);
|
|
let tls = ClientTlsConfig::new()
|
|
.ca_certificate(ca_cert)
|
|
.domain_name("localhost");
|
|
let endpoint = Endpoint::from_shared(format!("https://localhost:{}", addr.port()))
|
|
.expect("invalid endpoint")
|
|
.tls_config(tls)
|
|
.expect("failed to set tls");
|
|
let channel = endpoint.connect().await.expect("failed to connect");
|
|
OpenShellClient::with_interceptor(
|
|
channel,
|
|
CfInterceptor {
|
|
token: token.to_string(),
|
|
},
|
|
)
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct CfInterceptor {
|
|
token: String,
|
|
}
|
|
|
|
impl tonic::service::Interceptor for CfInterceptor {
|
|
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, Status> {
|
|
req.metadata_mut().insert(
|
|
"cf-authorization",
|
|
self.token.parse().expect("invalid metadata value"),
|
|
);
|
|
Ok(req)
|
|
}
|
|
}
|
|
|
|
fn build_tls_root(cert_pem: &[u8]) -> RootCertStore {
|
|
let mut roots = RootCertStore::empty();
|
|
let mut cursor = std::io::Cursor::new(cert_pem);
|
|
let parsed = certs(&mut cursor)
|
|
.collect::<Result<Vec<CertificateDer<'static>>, _>>()
|
|
.expect("failed to parse cert pem");
|
|
for cert in parsed {
|
|
roots.add(cert).expect("failed to add cert");
|
|
}
|
|
roots
|
|
}
|
|
|
|
/// Build an HTTPS client with mTLS.
|
|
fn https_client_mtls(
|
|
pki: &PkiBundle,
|
|
) -> Client<
|
|
hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
|
|
Empty<Bytes>,
|
|
> {
|
|
let roots = build_tls_root(&pki.ca_cert_pem);
|
|
let client_certs = {
|
|
let mut cursor = std::io::Cursor::new(&pki.client_cert_pem);
|
|
certs(&mut cursor)
|
|
.collect::<Result<Vec<CertificateDer<'static>>, _>>()
|
|
.expect("failed to parse client cert pem")
|
|
};
|
|
let client_key = {
|
|
let mut cursor = std::io::Cursor::new(&pki.client_key_pem);
|
|
rustls_pemfile::private_key(&mut cursor)
|
|
.expect("failed to parse client key pem")
|
|
.expect("no private key found")
|
|
};
|
|
let tls_config = rustls::ClientConfig::builder()
|
|
.with_root_certificates(roots)
|
|
.with_client_auth_cert(client_certs, client_key)
|
|
.expect("failed to build client TLS config with client cert");
|
|
let https = HttpsConnectorBuilder::new()
|
|
.with_tls_config(tls_config)
|
|
.https_only()
|
|
.enable_http1()
|
|
.build();
|
|
Client::builder(TokioExecutor::new()).build(https)
|
|
}
|
|
|
|
/// Build an HTTPS client *without* a client cert (simulates Cloudflare tunnel).
|
|
fn https_client_no_cert(
|
|
ca_pem: &[u8],
|
|
) -> Client<
|
|
hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
|
|
Empty<Bytes>,
|
|
> {
|
|
let roots = build_tls_root(ca_pem);
|
|
let tls_config = rustls::ClientConfig::builder()
|
|
.with_root_certificates(roots)
|
|
.with_no_client_auth();
|
|
let https = HttpsConnectorBuilder::new()
|
|
.with_tls_config(tls_config)
|
|
.https_only()
|
|
.enable_http1()
|
|
.build();
|
|
Client::builder(TokioExecutor::new()).build(https)
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Tests
|
|
// ===========================================================================
|
|
|
|
/// Valid client cert is accepted when a CA is configured.
|
|
#[tokio::test]
|
|
async fn mtls_valid_client_cert_accepted() {
|
|
install_rustls_provider();
|
|
let (temp, pki) = generate_pki();
|
|
|
|
let tls_acceptor = TlsAcceptor::from_files(
|
|
&temp.path().join("server-cert.pem"),
|
|
&temp.path().join("server-key.pem"),
|
|
Some(temp.path().join("ca.pem").as_path()),
|
|
false,
|
|
)
|
|
.unwrap();
|
|
|
|
let (addr, server) = start_test_server(tls_acceptor).await;
|
|
|
|
// gRPC
|
|
let mut grpc = grpc_client_mtls(
|
|
addr,
|
|
pki.ca_cert_pem.clone(),
|
|
pki.client_cert_pem.clone(),
|
|
pki.client_key_pem.clone(),
|
|
)
|
|
.await;
|
|
let resp = grpc.health(HealthRequest {}).await.unwrap();
|
|
assert_eq!(resp.get_ref().status, ServiceStatus::Healthy as i32);
|
|
|
|
// HTTP
|
|
let client = https_client_mtls(&pki);
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(format!("https://localhost:{}/healthz", addr.port()))
|
|
.body(Empty::<Bytes>::new())
|
|
.unwrap();
|
|
let resp = client.request(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
server.abort();
|
|
}
|
|
|
|
/// No client cert is accepted when a CA is configured — client certs are
|
|
/// always optional. Auth is deferred to the application layer.
|
|
#[tokio::test]
|
|
async fn no_client_cert_accepted_with_ca_configured() {
|
|
install_rustls_provider();
|
|
let (temp, pki) = generate_pki();
|
|
|
|
let tls_acceptor = TlsAcceptor::from_files(
|
|
&temp.path().join("server-cert.pem"),
|
|
&temp.path().join("server-key.pem"),
|
|
Some(temp.path().join("ca.pem").as_path()),
|
|
false,
|
|
)
|
|
.unwrap();
|
|
|
|
let (addr, server) = start_test_server(tls_acceptor).await;
|
|
|
|
// gRPC without client cert — should pass TLS handshake
|
|
let mut grpc = grpc_client_no_cert(addr, pki.ca_cert_pem.clone()).await;
|
|
let resp = grpc.health(HealthRequest {}).await.unwrap();
|
|
assert_eq!(
|
|
resp.get_ref().status,
|
|
ServiceStatus::Healthy as i32,
|
|
"gRPC health check should succeed without client cert"
|
|
);
|
|
|
|
// HTTP without client cert
|
|
let client = https_client_no_cert(&pki.ca_cert_pem);
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(format!("https://localhost:{}/healthz", addr.port()))
|
|
.body(Empty::<Bytes>::new())
|
|
.unwrap();
|
|
let resp = client.request(req).await.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::OK,
|
|
"HTTP health check should succeed without client cert"
|
|
);
|
|
|
|
server.abort();
|
|
}
|
|
|
|
/// Bearer auth header passes through to the gRPC handler when no client
|
|
/// cert is presented.
|
|
#[tokio::test]
|
|
async fn bearer_header_reaches_server_without_client_cert() {
|
|
install_rustls_provider();
|
|
let (temp, pki) = generate_pki();
|
|
|
|
let tls_acceptor = TlsAcceptor::from_files(
|
|
&temp.path().join("server-cert.pem"),
|
|
&temp.path().join("server-key.pem"),
|
|
Some(temp.path().join("ca.pem").as_path()),
|
|
false,
|
|
)
|
|
.unwrap();
|
|
|
|
let (addr, server) = start_test_server(tls_acceptor).await;
|
|
|
|
// gRPC without client cert but with cf-authorization header
|
|
let mut grpc =
|
|
grpc_client_with_cf_header(addr, pki.ca_cert_pem.clone(), "eyJhbGciOiJSUzI1NiJ9.test")
|
|
.await;
|
|
let resp = grpc.health(HealthRequest {}).await.unwrap();
|
|
assert_eq!(
|
|
resp.get_ref().status,
|
|
ServiceStatus::Healthy as i32,
|
|
"gRPC with bearer header should succeed without client cert"
|
|
);
|
|
|
|
server.abort();
|
|
}
|
|
|
|
/// A client cert from a rogue CA is rejected at the TLS layer even though
|
|
/// client certs are optional — presented certs are still validated.
|
|
#[tokio::test]
|
|
async fn rogue_cert_rejected() {
|
|
install_rustls_provider();
|
|
let (temp, pki) = generate_pki();
|
|
|
|
let tls_acceptor = TlsAcceptor::from_files(
|
|
&temp.path().join("server-cert.pem"),
|
|
&temp.path().join("server-key.pem"),
|
|
Some(temp.path().join("ca.pem").as_path()),
|
|
false,
|
|
)
|
|
.unwrap();
|
|
|
|
let (addr, server) = start_test_server(tls_acceptor).await;
|
|
|
|
// Generate a rogue CA + client cert
|
|
let mut rogue_ca_params =
|
|
CertificateParams::new(Vec::<String>::new()).expect("failed to create rogue CA params");
|
|
rogue_ca_params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
|
|
rogue_ca_params
|
|
.distinguished_name
|
|
.push(rcgen::DnType::CommonName, "rogue-ca");
|
|
let rogue_ca_key = KeyPair::generate().expect("failed to generate rogue CA key");
|
|
let rogue_ca_cert = rogue_ca_params
|
|
.self_signed(&rogue_ca_key)
|
|
.expect("failed to sign rogue CA cert");
|
|
|
|
let mut rogue_client_params =
|
|
CertificateParams::new(Vec::<String>::new()).expect("failed to create rogue client params");
|
|
rogue_client_params
|
|
.distinguished_name
|
|
.push(rcgen::DnType::CommonName, "rogue-client");
|
|
let rogue_client_key = KeyPair::generate().expect("failed to generate rogue client key");
|
|
let rogue_client_cert = rogue_client_params
|
|
.signed_by(&rogue_client_key, &rogue_ca_cert, &rogue_ca_key)
|
|
.expect("failed to sign rogue client cert");
|
|
|
|
let ca_cert = tonic::transport::Certificate::from_pem(pki.ca_cert_pem.clone());
|
|
let identity = tonic::transport::Identity::from_pem(
|
|
rogue_client_cert.pem(),
|
|
rogue_client_key.serialize_pem(),
|
|
);
|
|
let tls = ClientTlsConfig::new()
|
|
.ca_certificate(ca_cert)
|
|
.identity(identity)
|
|
.domain_name("localhost");
|
|
let endpoint = Endpoint::from_shared(format!("https://localhost:{}", addr.port()))
|
|
.expect("invalid endpoint")
|
|
.tls_config(tls)
|
|
.expect("failed to set tls");
|
|
|
|
let result = endpoint.connect().await;
|
|
if let Ok(channel) = result {
|
|
let mut client = OpenShellClient::new(channel);
|
|
let rpc_result = client.health(HealthRequest {}).await;
|
|
assert!(
|
|
rpc_result.is_err(),
|
|
"expected RPC to fail with rogue client cert"
|
|
);
|
|
}
|
|
// If connect() itself failed, that's also correct.
|
|
|
|
server.abort();
|
|
}
|
|
|
|
/// HTTPS-only mode: no client CA configured, so the server never requests
|
|
/// client certificates. Clients connect with server-only TLS.
|
|
#[tokio::test]
|
|
async fn https_only_no_client_cert_required() {
|
|
install_rustls_provider();
|
|
let (temp, pki) = generate_pki();
|
|
|
|
let tls_acceptor = TlsAcceptor::from_files(
|
|
&temp.path().join("server-cert.pem"),
|
|
&temp.path().join("server-key.pem"),
|
|
None,
|
|
false,
|
|
)
|
|
.unwrap();
|
|
|
|
let (addr, server) = start_test_server(tls_acceptor).await;
|
|
|
|
// gRPC without client cert — should succeed (no client certs requested)
|
|
let mut grpc = grpc_client_no_cert(addr, pki.ca_cert_pem.clone()).await;
|
|
let resp = grpc.health(HealthRequest {}).await.unwrap();
|
|
assert_eq!(
|
|
resp.get_ref().status,
|
|
ServiceStatus::Healthy as i32,
|
|
"gRPC health check should succeed in HTTPS-only mode"
|
|
);
|
|
|
|
// HTTP without client cert
|
|
let client = https_client_no_cert(&pki.ca_cert_pem);
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(format!("https://localhost:{}/healthz", addr.port()))
|
|
.body(Empty::<Bytes>::new())
|
|
.unwrap();
|
|
let resp = client.request(req).await.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::OK,
|
|
"HTTP health check should succeed in HTTPS-only mode"
|
|
);
|
|
|
|
server.abort();
|
|
}
|