diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d3ab65d..b3d56040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +- Bug fix: Honor CA certificates when they are supplied, either as part of a dynamic backend + definition or as part of a backend defined in fastly.toml. (In the latter case, CA certificates + can be added using the "ca_certificate" key.) + ## 0.9.4 (2024-02-22) - Added `delete_async` hostcall for KV stores ([#332](https://github.com/fastly/Viceroy/pull/332)) diff --git a/Cargo.lock b/Cargo.lock index 35e2cbf3..396713c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -547,6 +547,19 @@ dependencies = [ "typenum", ] +[[package]] +name = "dashmap" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd72493923899c6f10c641bdbdeddc7183d6396641d99c1a0d1597f37f92e28" +dependencies = [ + "cfg-if", + "hashbrown 0.14.3", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "debugid" version = "0.8.0" @@ -1787,6 +1800,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e56dd856803e253c8f298af3f4d7eb0ae5e23a737252cd90bb4f3b435033b2d" +dependencies = [ + "dashmap", + "futures", + "lazy_static", + "log", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d129178576168c589c9ec973feedf7d3126c01ac2bf08795109aa35b69fb8f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.10.8" @@ -2253,6 +2291,7 @@ dependencies = [ "rustls", "rustls-pemfile", "serde_json", + "serial_test", "tls-listener", "tokio", "tokio-rustls", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index e73c738b..1906c097 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -35,6 +35,7 @@ base64 = { workspace = true } hyper = { workspace = true } itertools = { workspace = true } serde_json = { workspace = true } +serial_test = "^2.0.0" clap = { workspace = true } rustls = { workspace = true } rustls-pemfile = { workspace = true } diff --git a/cli/tests/integration/client_certs.rs b/cli/tests/integration/client_certs.rs index 6931523b..a13d0f1a 100644 --- a/cli/tests/integration/client_certs.rs +++ b/cli/tests/integration/client_certs.rs @@ -128,6 +128,79 @@ fn build_server_tls_config() -> ServerConfig { } #[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn custom_ca_works() -> TestResult { + let test = Test::using_fixture("mutual-tls.wasm"); + let server_addr: SocketAddr = "127.0.0.1:0".parse().expect("localhost parses"); + let incoming = AddrIncoming::bind(&server_addr).expect("bind"); + let bound_port = incoming.local_addr().port(); + + let acceptor = TlsAcceptor::from(Arc::new(build_server_tls_config())); + let listener = TlsListener::new_hyper(acceptor, incoming); + + let service = make_service_fn(|stream: &TlsStream<_>| { + let (_, server_connection) = stream.get_ref(); + let peer_certs = server_connection.peer_certificates().map(|x| x.to_vec()); + async move { + Ok::<_, std::io::Error>(service_fn(move |_req| { + let peer_certs = peer_certs.clone(); + + async { + match peer_certs { + None => response::Builder::new() + .status(401) + .body("could not identify client certificate".to_string()), + Some(vec) if vec.len() != 1 => response::Builder::new() + .status(406) + .body(format!("can only handle 1 cert, got {}", vec.len())), + Some(mut cert_vec) => { + let Certificate(cert) = cert_vec.remove(0); + let base64_cert = general_purpose::STANDARD.encode(cert); + response::Builder::new().status(200).body(base64_cert) + } + } + } + })) + } + }); + let server = Server::builder(listener).serve(service); + tokio::spawn(server); + + // positive test: setting the CA should allow this + let resp = test + .against( + Request::post("/") + .header("port", bound_port) + .header("set-ca", "please") + .body("Hello, Viceroy!") + .unwrap(), + ) + .await; + let resp = resp.expect("got response"); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.into_body().read_into_string().await?, + "Hello, Viceroy!" + ); + + // negative test: if we don't set the CA, we should get a failure + let resp = test + .against( + Request::post("/") + .header("port", bound_port) + .body("Hello, Viceroy!") + .unwrap(), + ) + .await; + assert_eq!( + resp.expect("got response").status(), + StatusCode::SERVICE_UNAVAILABLE + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] async fn client_certs_work() -> TestResult { // Set up the test harness std::env::set_var( @@ -174,6 +247,7 @@ async fn client_certs_work() -> TestResult { .against( Request::post("/") .header("port", bound_port) + .header("set-ca", "please") .body("Hello, Viceroy!") .unwrap(), ) @@ -184,5 +258,7 @@ async fn client_certs_work() -> TestResult { "Hello, Viceroy!" ); + std::env::remove_var("SSL_CERT_FILE"); + Ok(()) } diff --git a/cli/tests/integration/common/backends.rs b/cli/tests/integration/common/backends.rs index e2e284d8..309620d4 100644 --- a/cli/tests/integration/common/backends.rs +++ b/cli/tests/integration/common/backends.rs @@ -75,6 +75,7 @@ impl TestBackends { use_sni: backend.use_sni, grpc: false, client_cert: None, + ca_certs: vec![], }; backends.insert(name.to_string(), Arc::new(backend_config)); } diff --git a/lib/src/config/backends.rs b/lib/src/config/backends.rs index 4c1bfd7a..feb1ffbc 100644 --- a/lib/src/config/backends.rs +++ b/lib/src/config/backends.rs @@ -16,6 +16,7 @@ pub struct Backend { pub use_sni: bool, pub grpc: bool, pub client_cert: Option, + pub ca_certs: Vec, } /// A map of [`Backend`] definitions, keyed by their name. @@ -127,6 +128,11 @@ mod deserialization { .transpose()? .unwrap_or(true); + let ca_certs = toml + .remove("ca_certificate") + .map(parse_ca_cert_section) + .unwrap_or_else(|| Ok(vec![]))?; + let grpc = toml .remove("grpc") .map(|grpc| { @@ -149,7 +155,60 @@ mod deserialization { grpc, // NOTE: Update when we support client certs in static backends client_cert: None, + ca_certs, }) } } + + fn parse_ca_cert_section( + ca_cert: Value, + ) -> Result, BackendConfigError> { + match ca_cert { + Value::String(ca_cert) if !ca_cert.trim().is_empty() => { + let mut cursor = std::io::Cursor::new(ca_cert); + rustls_pemfile::certs(&mut cursor) + .map_err(|e| BackendConfigError::InvalidCACertEntry(format!("Couldn't process certificate: {}", e))) + .map(|mut x| { + x.drain(..) + .map(rustls::Certificate) + .collect::>() + }) + } + Value::String(_) => Err(BackendConfigError::EmptyCACert), + + Value::Array(array) => { + let mut result = vec![]; + + for item in array.into_iter() { + let mut current = parse_ca_cert_section(item)?; + result.append(&mut current); + } + + Ok(result) + } + + Value::Table(mut table) => { + match table.remove("file") { + None => match table.remove("value") { + None => Err(BackendConfigError::InvalidCACertEntry("'ca_certificate' was a dictionary without a 'file' or 'value' field".to_string())), + Some(strval @ Value::String(_)) => parse_ca_cert_section(strval), + Some(_) => Err(BackendConfigError::InvalidCACertEntry("invalid format for 'value' field".to_string())), + }, + Some(Value::String(x)) => { + if !table.is_empty() { + return Err(BackendConfigError::InvalidCACertEntry(format!("unknown ca_certificate keys: {:?}", table.keys().collect::>()))); + } + + let data = std::fs::read_to_string(&x) + .map_err(|e| BackendConfigError::InvalidCACertEntry(format!("{}", e)))?; + parse_ca_cert_section(Value::String(data)) + } + + Some(_) => Err(BackendConfigError::InvalidCACertEntry("invalid format for file reference".to_string())), + } + } + + _ => Err(BackendConfigError::InvalidCACertEntry("unknown format for 'ca_certificates' field; should be a certificate string, a dictionary with a file reference, or an array of the previous".to_string())), + } + } } diff --git a/lib/src/config/unit_tests.rs b/lib/src/config/unit_tests.rs index 8f35c397..2d2ef9f4 100644 --- a/lib/src/config/unit_tests.rs +++ b/lib/src/config/unit_tests.rs @@ -1069,3 +1069,150 @@ mod inline_toml_geolocation_config_tests { } } } + +mod ca_cert_config_tests { + use super::read_local_server_config; + + #[test] + fn ca_certs_default_to_empty() { + let standard_backend = r#" + [backends] + [backends.dog] + url = "http://localhost:7676/dog-mocks" + "#; + + let basic = read_local_server_config(standard_backend).expect("can parse basic config"); + let dog_backend = basic.backends.0.get("dog").expect("fetch failed :("); + assert!(dog_backend.ca_certs.is_empty()); + } + + #[test] + fn reads_ca_certs() { + let ca_backend = r#" +[backends] +[backends.dog] +url = "http://localhost:7676/dog-mocks" + +[backends."shark.server"] +url = "http://localhost:7676/shark-mocks" +override_host = "somehost.com" +ca_certificate = ''' +-----BEGIN CERTIFICATE----- +MIIDqTCCApGgAwIBAgIUDXDr/2fouphqlB8iJASenWOr/XwwDQYJKoZIhvcNAQEL +BQAwZDELMAkGA1UEBhMCVVMxDzANBgNVBAgMBk9yZWdvbjERMA8GA1UEBwwIUG9y +dGxhbmQxEDAOBgNVBAoMB1ZpY2Vyb3kxHzAdBgkqhkiG9w0BCQEWEGF3aWNrQGZh +c3RseS5jb20wHhcNMjMwNzI3MDAwODU5WhcNMzMwNzI0MDAwODU5WjBkMQswCQYD +VQQGEwJVUzEPMA0GA1UECAwGT3JlZ29uMREwDwYDVQQHDAhQb3J0bGFuZDEQMA4G +A1UECgwHVmljZXJveTEfMB0GCSqGSIb3DQEJARYQYXdpY2tAZmFzdGx5LmNvbTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKxXdG4C6yEeLTtFPOXWTv1N +eEeJMLcAoupB9u3x0PYT+w+0ruAympviqGbEiyZL/qMKLYenLiQO+72VCISW5qfB +ZoCpwDxBon5TDUZ98JU93nVRml7uOg25G+KTs3aeJt6+rFDPNaNyxVcKgCuURB4y +mwgosLUvxoEffFnHlURETLN4aSGQ6TLp8YEJp4EudTVo/l+kdhm6sLZMBkmUxnnl +muEc8ePAr1igYchz2tbcWRjzxoUOuEdoKaW2OCElNObt2WYPWzHs+6p1K8+KyTRY +/pVOFtA43nuWmk++UHFthBAw9IqBuO0FMJr4SULnKfiTh5E9F+nZ0Q/1nfzzsAMC +AwEAAaNTMFEwHQYDVR0OBBYEFGYM6HhP8yZ17eXw5nOfQ971u1l9MB8GA1UdIwQY +MBaAFGYM6HhP8yZ17eXw5nOfQ971u1l9MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAFmFkUodKTXeT683GEj4SoiMbDL8d3x+Vc+kvLPC2Jloru4R +Qo0USu3eJZjNxKjmPbLii8gzf5ZmZHdytWQ+5irYjXBHrE9tPgmpavhM+0otpnUd +vYosnfwv/aQEIiqeMkpqzbSKvb2I+TVpAC1xb6qbYE95tnsX/KEdAoJ/SAcZLGYQ +LKGTjz3eKlgUWy69uwzHXkie8hxDVRlyA7cFY4AAqsLhL2KQPWtMT7fRKrVKfLYd +Qq7tJAMLnPnAdAUousI0RDcLpB8adGkhZH66lL4oV9U+aQ0dA0oiqSKZtMoHeWbr +/L0ti7ZOfxOxRRCzt8KdLo/kGNTfAz+74P0MY80= +-----END CERTIFICATE----- +''' +"#; + + let with_ca = read_local_server_config(ca_backend).expect("can parse backends with ca"); + let dog_backend = with_ca.backends.0.get("dog").expect("fetch failed :("); + assert!(dog_backend.ca_certs.is_empty()); + let shark_backend = with_ca + .backends + .0 + .get("shark.server") + .expect("no blåhaj :("); + assert!(!shark_backend.ca_certs.is_empty()); + } + + #[test] + fn reads_file_path_ca_certs() { + let ca_backend = format!( + r#" +[backends] +[backends.dog] +url = "http://localhost:7676/dog-mocks" + +[backends."shark.server"] +url = "http://localhost:7676/shark-mocks" +override_host = "somehost.com" +ca_certificate.file = {:?} +"#, + concat!(env!("CARGO_MANIFEST_DIR"), "/../test-fixtures/data/ca.pem") + ); + + let with_ca = read_local_server_config(&ca_backend).expect("can parse backends with ca"); + let dog_backend = with_ca.backends.0.get("dog").expect("fetch failed :("); + assert!(dog_backend.ca_certs.is_empty()); + let shark_backend = with_ca + .backends + .0 + .get("shark.server") + .expect("no blåhaj :("); + assert!(!shark_backend.ca_certs.is_empty()); + } + + #[test] + fn reads_multiple_ca_certs() { + let ca_backend = format!( + r#" +[backends] +[backends.dog] +url = "http://localhost:7676/dog-mocks" + +[backends."shark.server"] +url = "http://localhost:7676/shark-mocks" +override_host = "somehost.com" +[[backends."shark.server".ca_certificate]] +file = {:?} +[[backends."shark.server".ca_certificate]] +file = {:?} +[[backends."shark.server".ca_certificate]] +value = ''' +-----BEGIN CERTIFICATE----- +MIIDqTCCApGgAwIBAgIUDXDr/2fouphqlB8iJASenWOr/XwwDQYJKoZIhvcNAQEL +BQAwZDELMAkGA1UEBhMCVVMxDzANBgNVBAgMBk9yZWdvbjERMA8GA1UEBwwIUG9y +dGxhbmQxEDAOBgNVBAoMB1ZpY2Vyb3kxHzAdBgkqhkiG9w0BCQEWEGF3aWNrQGZh +c3RseS5jb20wHhcNMjMwNzI3MDAwODU5WhcNMzMwNzI0MDAwODU5WjBkMQswCQYD +VQQGEwJVUzEPMA0GA1UECAwGT3JlZ29uMREwDwYDVQQHDAhQb3J0bGFuZDEQMA4G +A1UECgwHVmljZXJveTEfMB0GCSqGSIb3DQEJARYQYXdpY2tAZmFzdGx5LmNvbTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKxXdG4C6yEeLTtFPOXWTv1N +eEeJMLcAoupB9u3x0PYT+w+0ruAympviqGbEiyZL/qMKLYenLiQO+72VCISW5qfB +ZoCpwDxBon5TDUZ98JU93nVRml7uOg25G+KTs3aeJt6+rFDPNaNyxVcKgCuURB4y +mwgosLUvxoEffFnHlURETLN4aSGQ6TLp8YEJp4EudTVo/l+kdhm6sLZMBkmUxnnl +muEc8ePAr1igYchz2tbcWRjzxoUOuEdoKaW2OCElNObt2WYPWzHs+6p1K8+KyTRY +/pVOFtA43nuWmk++UHFthBAw9IqBuO0FMJr4SULnKfiTh5E9F+nZ0Q/1nfzzsAMC +AwEAAaNTMFEwHQYDVR0OBBYEFGYM6HhP8yZ17eXw5nOfQ971u1l9MB8GA1UdIwQY +MBaAFGYM6HhP8yZ17eXw5nOfQ971u1l9MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAFmFkUodKTXeT683GEj4SoiMbDL8d3x+Vc+kvLPC2Jloru4R +Qo0USu3eJZjNxKjmPbLii8gzf5ZmZHdytWQ+5irYjXBHrE9tPgmpavhM+0otpnUd +vYosnfwv/aQEIiqeMkpqzbSKvb2I+TVpAC1xb6qbYE95tnsX/KEdAoJ/SAcZLGYQ +LKGTjz3eKlgUWy69uwzHXkie8hxDVRlyA7cFY4AAqsLhL2KQPWtMT7fRKrVKfLYd +Qq7tJAMLnPnAdAUousI0RDcLpB8adGkhZH66lL4oV9U+aQ0dA0oiqSKZtMoHeWbr +/L0ti7ZOfxOxRRCzt8KdLo/kGNTfAz+74P0MY80= +-----END CERTIFICATE----- +''' +"#, + concat!(env!("CARGO_MANIFEST_DIR"), "/../test-fixtures/data/ca.pem"), + concat!(env!("CARGO_MANIFEST_DIR"), "/../test-fixtures/data/ca.pem") + ); + + let with_ca = read_local_server_config(&ca_backend).expect("can parse backends with ca"); + let dog_backend = with_ca.backends.0.get("dog").expect("fetch failed :("); + assert!(dog_backend.ca_certs.is_empty()); + let shark_backend = with_ca + .backends + .0 + .get("shark.server") + .expect("no blåhaj :("); + assert_eq!(3, shark_backend.ca_certs.len()); + } +} diff --git a/lib/src/error.rs b/lib/src/error.rs index 6cc882a6..ad0b49c7 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -413,6 +413,12 @@ pub enum BackendConfigError { #[error("'cert_host' field was not a string")] InvalidCertHostEntry, + #[error("'ca_certificate' field is empty")] + EmptyCACert, + + #[error("'ca_certificate' field was invalid: {0}")] + InvalidCACertEntry(String), + #[error("'use_sni' field was not a boolean")] InvalidUseSniEntry, diff --git a/lib/src/upstream.rs b/lib/src/upstream.rs index 4bcbcb4f..e0882402 100644 --- a/lib/src/upstream.rs +++ b/lib/src/upstream.rs @@ -9,7 +9,7 @@ use crate::{ use futures::Future; use http::{uri, HeaderValue, Version}; use hyper::{client::HttpConnector, header, Client, HeaderMap, Request, Response, Uri}; -use rustls::client::{ServerName, WantsTransparencyPolicyOrClientCert}; +use rustls::client::ServerName; use std::{ io, pin::Pin, @@ -37,8 +37,8 @@ static GZIP_VALUES: [HeaderValue; 2] = [ /// SNI. #[derive(Clone)] pub struct TlsConfig { - partial_config: - rustls::ConfigBuilder, + partial_config: rustls::ConfigBuilder, + default_roots: rustls::RootCertStore, } impl TlsConfig { @@ -58,11 +58,12 @@ impl TlsConfig { warn!("no CA certificates available"); } - let partial_config = rustls::ClientConfig::builder() - .with_safe_defaults() - .with_root_certificates(roots); + let partial_config = rustls::ClientConfig::builder().with_safe_defaults(); - Ok(TlsConfig { partial_config }) + Ok(TlsConfig { + partial_config, + default_roots: roots, + }) } } @@ -117,17 +118,31 @@ impl hyper::service::Service for BackendConnector { // the future for establishing the TCP connection. we create this outside of the `async` // block to avoid capturing `http` let connect_fut = self.http.call(backend.uri.clone()); + let mut custom_roots = rustls::RootCertStore::empty(); + let (added, ignored) = custom_roots.add_parsable_certificates(&self.backend.ca_certs); + if ignored > 0 { + tracing::warn!( + "Ignored {} certificates in provided CA certificate.", + ignored + ); + } + let config = if self.backend.ca_certs.is_empty() { + config + .partial_config + .with_root_certificates(config.default_roots) + } else { + tracing::trace!("Using {} certificates from provided CA certificate.", added); + config.partial_config.with_root_certificates(custom_roots) + }; Box::pin(async move { let tcp = connect_fut.await.map_err(Box::new)?; if backend.uri.scheme_str() == Some("https") { let mut config = if let Some(certed_key) = &backend.client_cert { - config - .partial_config - .with_client_auth_cert(certed_key.certs(), certed_key.key())? + config.with_client_auth_cert(certed_key.certs(), certed_key.key())? } else { - config.partial_config.with_no_client_auth() + config.with_no_client_auth() }; config.enable_sni = backend.use_sni; if backend.grpc { diff --git a/lib/src/wiggle_abi/req_impl.rs b/lib/src/wiggle_abi/req_impl.rs index 29b80a5e..2545916a 100644 --- a/lib/src/wiggle_abi/req_impl.rs +++ b/lib/src/wiggle_abi/req_impl.rs @@ -304,6 +304,30 @@ impl FastlyHttpReq for Session { "http" }; + let ca_certs = + if (scheme == "https") && backend_info_mask.contains(BackendConfigOptions::CA_CERT) { + if config.ca_cert_len == 0 { + return Err(Error::InvalidArgument); + } + + if config.ca_cert_len > (64 * 1024) { + return Err(Error::InvalidArgument); + } + + let byte_slice = config + .ca_cert + .as_array(config.ca_cert_len) + .as_slice()? + .ok_or(Error::SharedMemory)?; + let mut byte_cursor = std::io::Cursor::new(&byte_slice[..]); + rustls_pemfile::certs(&mut byte_cursor)? + .drain(..) + .map(rustls::Certificate) + .collect() + } else { + vec![] + }; + let mut cert_host = if backend_info_mask.contains(BackendConfigOptions::CERT_HOSTNAME) { if config.cert_hostname_len == 0 { return Err(Error::InvalidArgument); @@ -399,6 +423,7 @@ impl FastlyHttpReq for Session { use_sni, grpc, client_cert, + ca_certs, }; if !self.add_backend(name, new_backend) { diff --git a/test-fixtures/src/bin/mutual-tls.rs b/test-fixtures/src/bin/mutual-tls.rs index 379f44f1..bcfe5787 100644 --- a/test-fixtures/src/bin/mutual-tls.rs +++ b/test-fixtures/src/bin/mutual-tls.rs @@ -19,28 +19,45 @@ fn main() -> Result<(), Error> { let backend = Backend::builder("mtls-backend", format!("localhost:{}", port)) .enable_ssl() - .provide_client_certificate(certificate, key_secret) - .finish() - .expect("can build backend"); + .provide_client_certificate(certificate, key_secret); + + let backend = if client_req.contains_header("set-ca") { + backend.ca_certificate(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/data/ca.pem" + ))) + } else { + backend + }; + + let backend = backend.finish().expect("can build backend"); let resp = Request::get("http://localhost/") .with_header("header", "is-a-thing") .with_body("hello") - .send(backend) - .unwrap(); - - assert_eq!(resp.get_status(), StatusCode::OK); - let body = resp.into_body().into_string(); - let mut cert_cursor = std::io::Cursor::new(certificate); - let mut info = rustls_pemfile::certs(&mut cert_cursor).expect("got certs"); - assert_eq!(info.len(), 1); - let reflected_cert = info.remove(0); - let base64_cert = general_purpose::STANDARD.encode(reflected_cert); - assert_eq!(body, base64_cert); - - Response::from_status(200) - .with_body("Hello, Viceroy!") - .send_to_client(); + .send(backend); + + match resp { + Ok(resp) => { + assert_eq!(resp.get_status(), StatusCode::OK); + let body = resp.into_body().into_string(); + let mut cert_cursor = std::io::Cursor::new(certificate); + let mut info = rustls_pemfile::certs(&mut cert_cursor).expect("got certs"); + assert_eq!(info.len(), 1); + let reflected_cert = info.remove(0); + let base64_cert = general_purpose::STANDARD.encode(reflected_cert); + assert_eq!(body, base64_cert); + + Response::from_status(200) + .with_body("Hello, Viceroy!") + .send_to_client(); + } + Err(e) => { + Response::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_body(format!("much sadness: {}", e)) + .send_to_client(); + } + } Ok(()) }