From 06d22a3cbafcfa33e1bca63088f82838f4074ad4 Mon Sep 17 00:00:00 2001 From: Evan Jones Date: Sun, 8 Sep 2024 13:08:43 -0700 Subject: [PATCH] feat(mTLS): adds mTLS support to dataplane api-server --- Cargo.lock | 1 + Makefile | 44 +++++ dataplane/api-server/Cargo.toml | 5 + dataplane/api-server/src/config.rs | 31 ++++ dataplane/api-server/src/lib.rs | 85 ++++++++- dataplane/api-server/tests/test_setup_tls.rs | 87 +++++++++ dataplane/loader/src/main.rs | 42 +++++ go.mod | 16 ++ go.sum | 21 +++ test/integration/certs/ca-config.json | 22 +++ test/integration/certs/ca-csr.json | 15 ++ test/integration/certs/client-csr.json | 19 ++ test/integration/certs/server-csr.json | 20 ++ test/integration/dataplane_mtls_test.go | 182 +++++++++++++++++++ 14 files changed, 584 insertions(+), 6 deletions(-) create mode 100644 dataplane/api-server/src/config.rs create mode 100644 dataplane/api-server/tests/test_setup_tls.rs create mode 100644 go.mod create mode 100644 go.sum create mode 100644 test/integration/certs/ca-config.json create mode 100644 test/integration/certs/ca-csr.json create mode 100644 test/integration/certs/client-csr.json create mode 100644 test/integration/certs/server-csr.json create mode 100644 test/integration/dataplane_mtls_test.go diff --git a/Cargo.lock b/Cargo.lock index d6dfcb89..38744519 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,6 +92,7 @@ version = "0.3.0" dependencies = [ "anyhow", "aya", + "clap", "common", "libc", "log", diff --git a/Makefile b/Makefile index 3b289516..dbf84c4a 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ UDP_SERVER_DOCKERFILE ?= build/Containerfile.udp_server # Other testing variables EXISTING_CLUSTER ?= +TEST_CERTS_PATH ?= test/integration/certs # Image URL to use all building/pushing image targets TAG ?= integration-tests @@ -99,12 +100,15 @@ $(LOCALBIN): mkdir -p $(LOCALBIN) ## Tool Binaries +CFSSL ?= $(LOCALBIN)/cfssl +CFSSLJSON ?= $(LOCALBIN)/cfssljson KUSTOMIZE ?= $(LOCALBIN)/kustomize ENVTEST ?= $(LOCALBIN)/setup-envtest KIND ?= $(LOCALBIN)/kind KTF ?= $(LOCALBIN)/ktf ## Tool Versions +CFSSL_VERSION ?= v1.6.5 KUSTOMIZE_VERSION ?= v5.3.0 CONTROLLER_TOOLS_VERSION ?= v0.14.0 KIND_VERSION ?= v0.22.0 @@ -115,6 +119,16 @@ KUSTOMIZE_INSTALL_SCRIPT ?= "https://raw.githubusercontent.com/kubernetes-sigs/k # Build Dependencies # ------------------------------------------------------------------------------ +.PHONY: cfssl +cfssl: $(CFSSL) ## Download cfssl locally if necessary +$(CFSSL): $(LOCALBIN) + test -s $(LOCALBIN)/cfssl || GOBIN=$(LOCALBIN) go install github.com/cloudflare/cfssl/cmd/cfssl@$(CFSSL_VERSION) + +.PHONY: cfssljson +cfssljson: $(CFSSLJSON) +$(CFSSLJSON): $(LOCALBIN) + test -s $(LOCALBIN)/cfssljson || GOBIN=$(LOCALBIN) go install github.com/cloudflare/cfssl/cmd/cfssljson@$(CFSSL_VERSION) + .PHONY: kustomize kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. $(KUSTOMIZE): $(LOCALBIN) @@ -216,6 +230,36 @@ lint: ## Lint Rust code test: ## Run tests cargo test -vv +.PHONY: test.gencert +test.gencert: cfssl cfssljson + $(CFSSL) gencert \ + -initca $(TEST_CERTS_PATH)/ca-csr.json | $(CFSSLJSON) -bare ca + $(CFSSL) gencert \ + -ca=ca.pem \ + -ca-key=ca-key.pem \ + -config=$(TEST_CERTS_PATH)/ca-config.json \ + -profile=server \ + $(TEST_CERTS_PATH)/server-csr.json | $(CFSSLJSON) -bare server + $(CFSSL) gencert \ + -ca=ca.pem \ + -ca-key=ca-key.pem \ + -config=$(TEST_CERTS_PATH)/ca-config.json \ + -profile=clinet \ + $(TEST_CERTS_PATH)/client-csr.json | $(CFSSLJSON) -bare client + mv *.pem *.csr $(TEST_CERTS_PATH) + +.PHONY: test.rmcert +test.rmcert: + rm $(TEST_CERTS_PATH)/{*.pem,*.csr} + +.PHONY: test.dataplane.integration +test.dataplane.integration: test.gencert + go clean -testcache + TEST_CERTS_PATH=$(TEST_CERTS_PATH) \ + BLIXT_DATAPLANE_IMAGE=$(BLIXT_DATAPLANE_IMAGE):$(TAG) \ + GOFLAGS="-tags=dataplane_tests" go test -race -v ./test/integration/... + $(MAKE) test.rmcert + .PHONY: test.integration.deprecated test.integration.deprecated: ## Run the deprecated Golang integration tests go clean -testcache diff --git a/dataplane/api-server/Cargo.toml b/dataplane/api-server/Cargo.toml index 3447eeb2..8b8803c8 100644 --- a/dataplane/api-server/Cargo.toml +++ b/dataplane/api-server/Cargo.toml @@ -8,6 +8,7 @@ version.workspace = true [dependencies] anyhow = { workspace = true } aya = { workspace = true, features = ["async_tokio"] } +clap = { workspace = true, features = ["derive"] } common = { workspace = true, features = ["user"] } libc = { workspace = true } log = { workspace = true } @@ -27,3 +28,7 @@ tonic-health = { workspace = true } [build-dependencies] tonic-build = { workspace = true } + +[dev-dependencies] +tempfile = "3.3.0" +rcgen = "0.9.3" diff --git a/dataplane/api-server/src/config.rs b/dataplane/api-server/src/config.rs new file mode 100644 index 00000000..8bd4eca8 --- /dev/null +++ b/dataplane/api-server/src/config.rs @@ -0,0 +1,31 @@ +/* +Copyright 2023 The Kubernetes Authors. + +SPDX-License-Identifier: (GPL-2.0-only OR BSD-2-Clause) +*/ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Debug, Subcommand)] +pub enum TLSConfig { + TLS(ServerOnlyTLSConfig), + MutualTLS(MutualTLSConfig), +} + +#[derive(Debug, Parser, Clone)] +pub struct ServerOnlyTLSConfig { + #[clap(short, long)] + pub server_certificate_path: PathBuf, + #[clap(short, long)] + pub server_private_key_path: PathBuf, +} + +#[derive(Debug, Parser, Clone)] +pub struct MutualTLSConfig { + #[clap(short, long)] + pub server_certificate_path: PathBuf, + #[clap(short, long)] + pub server_private_key_path: PathBuf, + #[clap(short, long)] + pub client_certificate_authority_root_path: PathBuf, +} diff --git a/dataplane/api-server/src/lib.rs b/dataplane/api-server/src/lib.rs index 2fd7dfd1..190cd736 100644 --- a/dataplane/api-server/src/lib.rs +++ b/dataplane/api-server/src/lib.rs @@ -5,17 +5,23 @@ SPDX-License-Identifier: (GPL-2.0-only OR BSD-2-Clause) */ pub mod backends; +pub mod config; pub mod netutils; pub mod server; -use std::net::{Ipv4Addr, SocketAddrV4}; +use std::{ + fs, + net::{Ipv4Addr, SocketAddrV4}, +}; -use anyhow::Error; +use anyhow::{Context, Result}; use aya::maps::{HashMap, MapData}; -use tonic::transport::Server; +use log::info; +use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig}; use backends::backends_server::BackendsServer; use common::{BackendKey, BackendList, ClientKey, LoadBalancerMapping}; +use config::TLSConfig; pub async fn start( addr: Ipv4Addr, @@ -23,15 +29,82 @@ pub async fn start( backends_map: HashMap, gateway_indexes_map: HashMap, tcp_conns_map: HashMap, -) -> Result<(), Error> { + tls_config: Option, +) -> Result<()> { let (_, health_service) = tonic_health::server::health_reporter(); let server = server::BackendService::new(backends_map, gateway_indexes_map, tcp_conns_map); - // TODO: mTLS https://github.com/Kong/blixt/issues/50 - Server::builder() + let mut server_builder = Server::builder(); + server_builder = setup_tls(server_builder, &tls_config)?; + server_builder .add_service(health_service) .add_service(BackendsServer::new(server)) .serve(SocketAddrV4::new(addr, port).into()) .await?; Ok(()) } + +pub fn setup_tls(mut builder: Server, tls_config: &Option) -> Result { + // TLS implementation drawn from Tonic examples. + // See: https://github.com/hyperium/tonic/blob/master/examples/src/tls_client_auth/server.rs + match tls_config { + Some(TLSConfig::TLS(config)) => { + let mut tls = ServerTlsConfig::new(); + + let cert = fs::read_to_string(&config.server_certificate_path).with_context(|| { + format!( + "Failed to read certificate from {:?}", + config.server_certificate_path + ) + })?; + let key = fs::read_to_string(&config.server_private_key_path).with_context(|| { + format!( + "Failed to read key from {:?}", + config.server_private_key_path + ) + })?; + let server_identity = Identity::from_pem(cert, key); + tls = tls.identity(server_identity); + + builder = builder.tls_config(tls)?; + info!("gRPC TLS enabled"); + Ok(builder) + } + Some(TLSConfig::MutualTLS(config)) => { + let mut tls = ServerTlsConfig::new(); + + let cert = + fs::read_to_string(config.server_certificate_path.clone()).with_context(|| { + format!( + "Failed to read certificate from {:?}", + config.server_certificate_path + ) + })?; + let key = + fs::read_to_string(config.server_private_key_path.clone()).with_context(|| { + format!( + "Failed to read key from {:?}", + config.server_private_key_path + ) + })?; + let server_identity = Identity::from_pem(cert, key); + tls = tls.identity(server_identity); + + let client_ca_cert = + fs::read_to_string(config.client_certificate_authority_root_path.clone()) + .with_context(|| { + format!( + "Failed to read client CA from {:?}", + config.client_certificate_authority_root_path + ) + })?; + let client_ca_root = Certificate::from_pem(client_ca_cert); + tls = tls.client_ca_root(client_ca_root); + + builder = builder.tls_config(tls)?; + info!("gRPC mTLS enabled"); + Ok(builder) + } + None => Ok(builder), + } +} diff --git a/dataplane/api-server/tests/test_setup_tls.rs b/dataplane/api-server/tests/test_setup_tls.rs new file mode 100644 index 00000000..540e0c2b --- /dev/null +++ b/dataplane/api-server/tests/test_setup_tls.rs @@ -0,0 +1,87 @@ +use anyhow::Result; +use api_server::config::{MutualTLSConfig, ServerOnlyTLSConfig, TLSConfig}; +use api_server::setup_tls; +use rcgen::{generate_simple_self_signed, Certificate, CertificateParams}; +use std::fs; +use tempfile::tempdir; +use tonic::transport::Server; + +#[tokio::test] +async fn test_tls_self_signed_cert() -> Result<()> { + // Create a temporary directory + let temp_dir = tempdir().unwrap(); + + // Generate self-signed certificate + let cert = generate_simple_self_signed(vec!["localhost".into()])?; + let cert_pem = cert.serialize_pem()?; + let key_pem = cert.serialize_private_key_pem(); + + // Paths for the server cert and private key + let cert_path = temp_dir.path().join("server.crt"); + let key_path = temp_dir.path().join("server.key"); + + // Write cert and key to temp files + fs::write(&cert_path, cert_pem.as_bytes())?; + fs::write(&key_path, key_pem.as_bytes())?; + + // Set up a TLS config with paths to the cert and key + let tls_config = Some(TLSConfig::TLS(ServerOnlyTLSConfig { + server_certificate_path: cert_path.clone(), + server_private_key_path: key_path.clone(), + })); + + // Prepare a dummy server builder + let builder = Server::builder(); + + // Run the setup_tls function and ensure no error is thrown + let result = setup_tls(builder, &tls_config); + assert!( + result.is_ok(), + "setup_tls should succeed with valid self-signed certs" + ); + Ok(()) +} + +#[tokio::test] +async fn test_mtls_self_signed_cert() -> Result<()> { + // Create a temporary directory + let temp_dir = tempdir().unwrap(); + + // Generate self-signed certificate + let cert = generate_simple_self_signed(vec!["localhost".into()])?; + let cert_pem = cert.serialize_pem()?; + let key_pem = cert.serialize_private_key_pem(); + + // Generate CA + let ca_params = CertificateParams::default(); + let ca_cert = Certificate::from_params(ca_params)?; + let ca_cert_pem = ca_cert.serialize_pem()?; + + // Cert file paths + let cert_path = temp_dir.path().join("server.crt"); + let key_path = temp_dir.path().join("server.key"); + let ca_cert_path = temp_dir.path().join("ca.crt"); + + // Write cert and key to temp files + fs::write(&cert_path, cert_pem.as_bytes())?; + fs::write(&key_path, key_pem.as_bytes())?; + fs::write(&ca_cert_path, ca_cert_pem.as_bytes())?; + + // Set up a TLS config with paths to the cert and key + let tls_config = Some(TLSConfig::MutualTLS(MutualTLSConfig { + server_certificate_path: cert_path.clone(), + server_private_key_path: key_path.clone(), + client_certificate_authority_root_path: ca_cert_path.clone(), + })); + + // Prepare a dummy server builder + let builder = Server::builder(); + + // Run the setup_tls function and ensure no error is thrown + let result = setup_tls(builder, &tls_config); + assert!( + result.is_ok(), + "setup_tls should succeed with valid self-signed certs" + ); + Ok(()) +} diff --git a/dataplane/loader/src/main.rs b/dataplane/loader/src/main.rs index ae33d14b..ca0d028f 100644 --- a/dataplane/loader/src/main.rs +++ b/dataplane/loader/src/main.rs @@ -7,6 +7,7 @@ SPDX-License-Identifier: (GPL-2.0-only OR BSD-2-Clause) use std::net::Ipv4Addr; use anyhow::Context; +use api_server::config::TLSConfig; use api_server::start as start_api_server; use aya::maps::HashMap; use aya::programs::{tc, SchedClassifier, TcAttachType}; @@ -16,12 +17,51 @@ use clap::Parser; use common::{BackendKey, BackendList, ClientKey, LoadBalancerMapping}; use log::{info, warn}; +/// Command-line options for the application. +/// +/// This struct defines the options available for the command-line interface, +/// including an interface name (`iface`) and an optional TLS configuration (`tls_config`). #[derive(Debug, Parser)] struct Opt { + /// Name of the network interface to attach the eBPF programs to. + /// + /// By default, this is set to `"lo"` (the loopback interface). #[clap(short, long, default_value = "lo")] iface: String, + /// Optional TLS configuration for securing the API server. + /// + /// If no TLS configuration is provided, the server will start without TLS. + /// You can specify either `tls` for server-only TLS or `mutual-tls` for mutual TLS. + #[clap(subcommand)] + tls_config: Option, } +/// Main function for the application. +/// +/// This function sets up and runs eBPF programs on the specified network interface +/// and optionally configures TLS for the API server. +/// +/// The program supports an optional TLS configuration, allowing the user to choose between: +/// - `tls`: Server-only TLS. +/// - `mutual-tls`: Mutual TLS, where both server and client authenticate with certificates. +/// +/// # Arguments +/// +/// - `iface`: The network interface to attach the eBPF programs to. +/// - `tls_config`: Optional subcommand to configure TLS for the API server. +/// +/// # Example +/// +/// ```bash +/// # Running with default interface and no TLS config: +/// $ dataplane +/// +/// # Running with a specified interface and server-only TLS config: +/// $ dataplane --iface eth0 tls --server-certificate-path /path/to/cert --server-private-key-path /path/to/key +/// +/// # Running with mutual TLS config: +/// $ dataplane --iface eth0 mutual-tls --server-certificate-path /path/to/cert --server-private-key-path /path/to/key --client-certificate-authority-root-path /path/to/ca +/// ``` #[tokio::main] async fn main() -> Result<(), anyhow::Error> { let opt = Opt::parse(); @@ -62,6 +102,7 @@ async fn main() -> Result<(), anyhow::Error> { .context("failed to attach the egress TC program")?; info!("starting api server"); + info!("Using tls config: {:?}", &opt.tls_config); let backends: HashMap<_, BackendKey, BackendList> = HashMap::try_from( bpf_program .take_map("BACKENDS") @@ -84,6 +125,7 @@ async fn main() -> Result<(), anyhow::Error> { backends, gateway_indexes, tcp_conns, + opt.tls_config, ) .await?; diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..17f34472 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/kubernetes-sigs/blixt + +go 1.23.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.9.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/text v0.17.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect + google.golang.org/grpc v1.67.1 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..9d02dfde --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/integration/certs/ca-config.json b/test/integration/certs/ca-config.json new file mode 100644 index 00000000..4d5ee573 --- /dev/null +++ b/test/integration/certs/ca-config.json @@ -0,0 +1,22 @@ +{ + "signing": { + "profiles": { + "server": { + "expiry": "1h", + "usages": [ + "signing", + "key encipherment", + "server auth" + ] + }, + "client": { + "expiry": "1h", + "usages": [ + "signing", + "key encipherment", + "client auth" + ] + } + } + } +} diff --git a/test/integration/certs/ca-csr.json b/test/integration/certs/ca-csr.json new file mode 100644 index 00000000..08053d7c --- /dev/null +++ b/test/integration/certs/ca-csr.json @@ -0,0 +1,15 @@ +{ + "CN": "Blixt Test CA", + "key": { + "algo": "rsa", + "size": 2048 + }, + "names": [ + {"C": "USA", + "L": "MI", + "ST": "Detroit", + "O": "Blixt is Awesome", + "OU": "CA Services" + } + ] +} diff --git a/test/integration/certs/client-csr.json b/test/integration/certs/client-csr.json new file mode 100644 index 00000000..7609e9ca --- /dev/null +++ b/test/integration/certs/client-csr.json @@ -0,0 +1,19 @@ +{ + "CN": "client", + "hosts": [ + "" + ], + "key": { + "algo": "rsa", + "size": 2048 + }, + "names": [ + { + "C": "USA", + "L": "MI", + "ST": "Detroit", + "O": "Blixt is Awesome", + "OU": "Distributed Services" + } + ] +} diff --git a/test/integration/certs/server-csr.json b/test/integration/certs/server-csr.json new file mode 100644 index 00000000..f36a8f66 --- /dev/null +++ b/test/integration/certs/server-csr.json @@ -0,0 +1,20 @@ +{ + "CN": "127.0.0.1", + "hosts": [ + "localhost", + "127.0.0.1" + ], + "key": { + "algo": "rsa", + "size": 2048 + }, + "names": [ + { + "C": "USA", + "L": "MI", + "ST": "Detroit", + "O": "Blixt is Awesome", + "OU": "Distributed Services" + } + ] +} diff --git a/test/integration/dataplane_mtls_test.go b/test/integration/dataplane_mtls_test.go new file mode 100644 index 00000000..5b37dc65 --- /dev/null +++ b/test/integration/dataplane_mtls_test.go @@ -0,0 +1,182 @@ +//go:build dataplane_tests +// +build dataplane_tests + +/* +Copyright 2023 The Kubernetes 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. +*/ +package integration + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +var ( + CAFile = certFile("ca.pem") + ClientCertFile = certFile("client.pem") + ClientKeyFile = certFile("client-key.pem") + ServerCertFile = certFile("server.pem") + ServerKeyFile = certFile("server-key.pem") +) + +// TestGRPCClient tests the gRPC client against a running Docker container. +func TestGRPCClient(t *testing.T) { + // Step 1: Get the image name from the environment + imageName := os.Getenv("BLIXT_DATAPLANE_IMAGE") + if imageName == "" { + t.Fatal("Environment variable BLIXT_DATAPLANE_IMAGE must be set") + } + + // Step 2: Build and run the Docker image + containerName := "test_dataplane" + if err := runDockerImage(containerName, imageName); err != nil { + t.Fatalf("Failed to run Docker image: %v", err) + } + defer cleanupDockerImage(containerName) + + // Step 3: Wait for the Docker container to be ready + if err := waitForContainer(containerName); err != nil { + t.Fatalf("Container did not start successfully: %v", err) + } + + // Step 4: Load TLS config + clientTLSConfig, err := setupTLSConfig(TLSConfig{ + CAFile: CAFile, + CertFile: ClientCertFile, + KeyFile: ClientKeyFile, + }) + require.NoError(t, err) + + // Step 5: Dial grpc to test hand-shake + clientCreds := credentials.NewTLS(clientTLSConfig) + conn, err := grpc.Dial("localhost:9874", grpc.WithTransportCredentials(clientCreds)) + require.NoError(t, err) + defer conn.Close() +} + +// Helper functions +func certFile(filename string) string { + return filepath.Join("certs", filename) + if dir := os.Getenv("TEST_CERTS_PATH"); dir != "" { + return filepath.Join(dir, filename) + } + panic("Env var TEST_CERTS_PATH not found. Please specify path to mTLS test certs") +} + +func setupTLSConfig(cfg TLSConfig) (*tls.Config, error) { + var err error + tlsConfig := &tls.Config{} + if cfg.CertFile != "" && cfg.KeyFile != "" { + tlsConfig.Certificates = make([]tls.Certificate, 1) + tlsConfig.Certificates[0], err = tls.LoadX509KeyPair( + cfg.CertFile, + cfg.KeyFile, + ) + if err != nil { + return nil, err + } + } + if cfg.CAFile != "" { + b, err := ioutil.ReadFile(cfg.CAFile) + if err != nil { + return nil, err + } + ca := x509.NewCertPool() + ok := ca.AppendCertsFromPEM([]byte(b)) + if !ok { + return nil, fmt.Errorf( + "failed to parse root certificate: %q", + cfg.CAFile) + } + if cfg.Server { + tlsConfig.ClientCAs = ca + tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert + } else { + tlsConfig.RootCAs = ca + } + tlsConfig.ServerName = cfg.ServerAddress + } + return tlsConfig, nil +} + +type TLSConfig struct { + CertFile string + KeyFile string + CAFile string + ServerAddress string + Server bool +} + +// runCommand is a wrapper around exec.Command to provide more informative +// errors back to the user +func runCommand(command string) error { + cmd := exec.Command("sh", "-c", command) + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("Error executing command: %s\nOutput: %s\n", command, string(output)) + return err + } + fmt.Printf("Command succeeded: %s\nOutput: %s\n", command, string(output)) + return nil +} + +// runDockerImage starts the Docker container with the specified name. +func runDockerImage(containerName, imageName string) error { + cmd := exec.Command( + "docker", + "run", + "--name", containerName, + "-d", + "-p", "9874:9874", + "-v", os.Getenv("PWD")+"/certs:/app/certs", + imageName, + "mutual-tls", + "--server-certificate-path", "/app/certs/server.pem", + "--server-private-key-path", "/app/certs/server-key.pem", + "--client-certificate-authority-root-path", "/app/certs/root.pem", + ) + return cmd.Run() +} + +// waitForContainer waits until the specified Docker container is running. +func waitForContainer(containerName string) error { + for i := 0; i < 10; i++ { // Wait up to 10 seconds + cmd := exec.Command("docker", "inspect", "-f", "{{.State.Running}}", containerName) + output, err := cmd.Output() + if err == nil && string(output) == "true\n" { + print(output) + return nil + } + time.Sleep(1 * time.Second) + } + return fmt.Errorf("container %s is not running", containerName) +} + +// cleanupDockerImage removes the Docker container. +func cleanupDockerImage(containerName string) { + cmd := exec.Command("docker", "rm", "-f", containerName) + cmd.Run() +}