diff --git a/Cargo.lock b/Cargo.lock index 41991eef8f4..09f029955c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,12 @@ dependencies = [ "backtrace", ] +[[package]] +name = "arc-swap" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" + [[package]] name = "asn1-rs" version = "0.5.2" @@ -342,6 +348,50 @@ dependencies = [ "syn 2.0.29", ] +[[package]] +name = "axum-server" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447f28c85900215cc1bea282f32d4a2f22d55c5a300afdfbc661c8d6a632e063" +dependencies = [ + "arc-swap", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "axum_tls" +version = "0.13.1" +dependencies = [ + "anyhow", + "assert_matches", + "axum", + "axum-server", + "camino", + "futures", + "hyper", + "pin-project", + "rcgen", + "reqwest", + "rustls", + "rustls-pemfile", + "tempfile", + "tokio", + "tokio-rustls", + "tower", + "tracing", + "x509-parser 0.14.0", +] + [[package]] name = "az_mapper_ext" version = "0.13.1" @@ -615,16 +665,22 @@ dependencies = [ name = "c8y_auth_proxy" version = "0.13.1" dependencies = [ + "anyhow", "axum", + "axum-server", + "axum_tls", "c8y_http_proxy", + "camino", "env_logger", "futures", "hyper", - "miette", "mockito", + "rcgen", "reqwest", + "rustls", "tedge_actors", "tedge_config", + "tedge_config_macros", "tedge_http_ext", "tokio", "tracing", @@ -686,6 +742,7 @@ dependencies = [ name = "c8y_http_proxy" version = "0.13.1" dependencies = [ + "anyhow", "async-trait", "c8y_api", "download", @@ -694,6 +751,7 @@ dependencies = [ "log", "mockito", "mqtt_channel", + "reqwest", "serde", "serde_json", "tedge_actors", @@ -1166,11 +1224,13 @@ version = "0.13.1" dependencies = [ "anyhow", "backoff", + "hyper", "log", "mockito", "nix", "regex", "reqwest", + "rustls", "serde", "serde_json", "tedge_utils", @@ -2462,6 +2522,7 @@ dependencies = [ "csv", "download", "logged_command", + "reqwest", "serde", "serde_json", "serial_test", @@ -3538,6 +3599,7 @@ dependencies = [ "log", "path-clean", "plugin_sm", + "reqwest", "routerify", "serde", "serde_json", @@ -3645,6 +3707,7 @@ dependencies = [ "collectd_ext", "flockfile", "mqtt_channel", + "reqwest", "tedge_actors", "tedge_api", "tedge_config", @@ -3729,6 +3792,7 @@ dependencies = [ "figment", "mqtt_channel", "once_cell", + "reqwest", "serde", "serde_ignored", "strum_macros", @@ -3812,6 +3876,7 @@ dependencies = [ "download", "log", "mockito", + "reqwest", "tedge_actors", "tedge_test_utils", "tedge_utils", diff --git a/Cargo.toml b/Cargo.toml index 7d06d673ce5..823d9a9ae4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ async-tungstenite = { version = "0.23", features = [ "tokio-runtime", "tokio-rustls-native-certs", ] } +axum_tls = { path = "crates/common/axum_tls" } +axum-server = { version = "0.5.1", features = ["tls-rustls"] } aws_mapper_ext = { path = "crates/extensions/aws_mapper_ext" } axum = "0.6" az_mapper_ext = { path = "crates/extensions/az_mapper_ext" } @@ -101,6 +103,7 @@ once_cell = "1.8" pad = "0.1" path-clean = "0.1" pem = "1.0" +pin-project = { version = "1.1.3", features = [] } plugin_sm = { path = "crates/core/plugin_sm" } predicates = "2.1" proc-macro2 = "1" @@ -151,6 +154,8 @@ tedge_test_utils = { path = "crates/tests/tedge_test_utils" } tedge_timer_ext = { path = "crates/extensions/tedge_timer_ext" } tedge_utils = { path = "crates/common/tedge_utils" } tedge-watchdog = { path = "crates/core/tedge_watchdog" } +tokio-rustls = "0.24.1" +tower = "0.4.13" tempfile = "3.5" test-case = "2.2" thiserror = "1.0" diff --git a/crates/common/axum_tls/Cargo.toml b/crates/common/axum_tls/Cargo.toml new file mode 100644 index 00000000000..7eb6d008ac2 --- /dev/null +++ b/crates/common/axum_tls/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "axum_tls" +version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = { workspace = true } +axum = { workspace = true } +axum-server = { workspace = true } +camino = { workspace = true } +futures = { workspace = true } +hyper = { workspace = true } +pin-project = { workspace = true } +rustls = { workspace = true } +rustls-pemfile = { workspace = true } +tokio = { workspace = true } +tokio-rustls = { workspace = true } +tower = { workspace = true } +tracing = { workspace = true } +x509-parser = { workspace = true } + +[dev-dependencies] +assert_matches = { workspace = true } +rcgen = { workspace = true } +reqwest = { workspace = true, features = ["rustls-tls-native-roots"] } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros"] } diff --git a/crates/common/axum_tls/src/acceptor.rs b/crates/common/axum_tls/src/acceptor.rs new file mode 100644 index 00000000000..4c2cdd146df --- /dev/null +++ b/crates/common/axum_tls/src/acceptor.rs @@ -0,0 +1,288 @@ +use crate::maybe_tls::MaybeTlsStream; + +use axum::middleware::AddExtension; +use axum::Extension; +use axum_server::accept::Accept; +use axum_server::accept::DefaultAcceptor; +use axum_server::tls_rustls::RustlsAcceptor; +use axum_server::tls_rustls::RustlsConfig; + +use futures::future::BoxFuture; + +use rustls::ServerConfig; + +use std::io; +use std::sync::Arc; + +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; +use tokio::io::BufReader; +use tower::Layer; +use x509_parser::prelude::FromDer; +use x509_parser::prelude::X509Certificate; + +#[derive(Debug, Clone)] +pub struct Acceptor { + inner: RustlsAcceptor, +} + +impl From for Acceptor { + fn from(config: ServerConfig) -> Self { + Self::new(config) + } +} + +#[derive(Debug, Clone)] +pub struct TlsData { + pub common_name: Option>, + pub is_secure: bool, +} + +/// An [axum_server::Acceptor] that accepts TLS connections via [rustls] +impl Acceptor { + pub fn new(config: ServerConfig) -> Self { + Self { + inner: RustlsAcceptor::new(RustlsConfig::from_config(Arc::new(config))), + } + } +} + +impl Accept for Acceptor +where + I: AsyncRead + AsyncWrite + Unpin + Send + 'static, + S: Send + 'static, +{ + type Stream = MaybeTlsStream; + type Service = AddExtension; + type Future = BoxFuture<'static, io::Result<(Self::Stream, Self::Service)>>; + + fn accept(&self, stream: I, service: S) -> Self::Future { + let acceptor = self.inner.clone(); + + Box::pin(async move { + let mut stream = BufReader::new(stream); + let first_bytes = stream.fill_buf().await?; + + // To handle HTTP and HTTPS requests from the same server, we have to just guess + // which is being used. The best approximation I can come up with is that HTTP + // requests have a header section that is valid ASCII, and HTTPS requests will + // contain some binary data that won't be valid ASCII (or UTF-8). As we're dealing + // with ASCII, splitting the string at the byte level is guaranteed not to split a + // UTF-8 code point, so [..20] just gets the first 20 characters of the string + // (assuming it is a valid ASCII sequence) + if std::str::from_utf8(&first_bytes[..20]).is_ok() { + let acceptor = DefaultAcceptor; + let (stream, service) = acceptor.accept(stream, service).await?; + let certificate_info = TlsData { + common_name: None, + is_secure: false, + }; + + let service = Extension(certificate_info).layer(service); + Ok((MaybeTlsStream::Insecure(stream), service)) + } else { + let (stream, service) = acceptor.accept(stream, service).await?; + let server_conn = stream.get_ref().1; + let cert = (|| { + X509Certificate::from_der(&server_conn.peer_certificates()?.first()?.0).ok() + })(); + let certificate_info = TlsData { + common_name: common_name(cert.as_ref()).map(Arc::from), + is_secure: true, + }; + let service = Extension(certificate_info).layer(service); + + Ok((MaybeTlsStream::Tls(Box::new(stream)), service)) + } + }) + } +} + +pub fn common_name<'a>(cert: Option<&'a (&[u8], X509Certificate)>) -> Option<&'a str> { + cert?.1.subject.iter_common_name().next()?.as_str().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ssl_config; + use axum::http::uri::Scheme; + use axum::routing::get; + use axum::Router; + use reqwest::Certificate; + use reqwest::Client; + use reqwest::Identity; + use rustls::RootCertStore; + use std::error::Error; + use std::net::SocketAddr; + use std::net::TcpListener; + + #[tokio::test] + async fn acceptor_accepts_non_tls_connections() { + let server = Server::without_trusted_roots(); + let client = Client::new(); + + assert_eq!( + server.get_with_scheme(Scheme::HTTP, &client).await.unwrap(), + "server is working" + ); + } + + #[tokio::test] + async fn acceptor_accepts_tls_connections() { + let server = Server::without_trusted_roots(); + let client = Client::builder() + .add_root_certificate(server.certificate.clone()) + .build() + .unwrap(); + + assert_eq!( + server + .get_with_scheme(Scheme::HTTPS, &client) + .await + .unwrap(), + "server is working" + ); + } + + #[tokio::test] + async fn acceptor_ignores_client_certificates_when_authentication_is_disabled() { + let server = Server::without_trusted_roots(); + let client = Client::builder() + .add_root_certificate(server.certificate.clone()) + .identity(identity_with_name("my-client")) + .build() + .unwrap(); + + assert_eq!( + server + .get_with_scheme(Scheme::HTTPS, &client) + .await + .unwrap(), + "server is working" + ); + } + + #[tokio::test] + async fn acceptor_rejects_untrusted_client_certificates() { + let server = Server::with_trusted_roots(RootCertStore::empty()); + let client = Client::builder() + .add_root_certificate(server.certificate.clone()) + .identity(identity_with_name("my-client")) + .build() + .unwrap(); + + let err = server + .get_with_scheme(Scheme::HTTPS, &client) + .await + .unwrap_err(); + assert_matches::assert_matches!( + rustls_error_from_reqwest(&err), + rustls::Error::AlertReceived(rustls::AlertDescription::UnknownCA) + ); + } + + #[tokio::test] + async fn acceptor_accepts_trusted_client_certificates() { + let client_cert = rcgen::generate_simple_self_signed(["my-client".into()]).unwrap(); + let identity = identity_from(&client_cert); + let mut cert_store = RootCertStore::empty(); + cert_store.add_parsable_certificates(&[client_cert.serialize_der().unwrap()]); + + let server = Server::with_trusted_roots(cert_store); + let client = Client::builder() + .add_root_certificate(server.certificate.clone()) + .identity(identity) + .build() + .unwrap(); + + assert_eq!( + server + .get_with_scheme(Scheme::HTTPS, &client) + .await + .unwrap(), + "server is working" + ); + } + + fn rustls_error_from_reqwest(err: &reqwest::Error) -> &rustls::Error { + (|| { + err.source()? + .downcast_ref::()? + .source()? + .downcast_ref::()? + .get_ref()? + .downcast_ref::() + })() + .unwrap() + } + + struct Server { + certificate: Certificate, + port: u16, + } + + fn identity_with_name(name: &str) -> Identity { + let client_cert = rcgen::generate_simple_self_signed([name.into()]).unwrap(); + identity_from(&client_cert) + } + + fn identity_from(cert: &rcgen::Certificate) -> Identity { + let mut pem = cert.serialize_private_key_pem().into_bytes(); + pem.append(&mut cert.serialize_pem().unwrap().into_bytes()); + Identity::from_pem(&pem).unwrap() + } + + impl Server { + async fn get_with_scheme( + &self, + protocol: Scheme, + client: &Client, + ) -> reqwest::Result { + let uri = format!("{protocol}://localhost:{}/test", self.port); + client + .get(uri) + .send() + .await? + .error_for_status()? + .text() + .await + } + + fn without_trusted_roots() -> Self { + Self::start(None) + } + + fn with_trusted_roots(root_cert_store: RootCertStore) -> Self { + Self::start(Some(root_cert_store)) + } + + fn start(trusted_roots: Option) -> Self { + let mut port = 3000; + let listener = loop { + if let Ok(listener) = TcpListener::bind::(([127, 0, 0, 1], port).into()) + { + break listener; + } + port += 1; + }; + let certificate = rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap(); + let certificate_der = certificate.serialize_der().unwrap(); + let private_key_der = certificate.serialize_private_key_der(); + let certificate = reqwest::Certificate::from_der(&certificate_der).unwrap(); + let config = ssl_config(vec![certificate_der], private_key_der, trusted_roots).unwrap(); + tokio::spawn( + axum_server::from_tcp(listener) + .acceptor(Acceptor::from(config.clone())) + .serve( + Router::new() + .route("/test", get(|| async { "server is working" })) + .into_make_service(), + ), + ); + + Self { port, certificate } + } + } +} diff --git a/crates/common/axum_tls/src/files.rs b/crates/common/axum_tls/src/files.rs new file mode 100644 index 00000000000..397db0fec87 --- /dev/null +++ b/crates/common/axum_tls/src/files.rs @@ -0,0 +1,347 @@ +use anyhow::anyhow; +use anyhow::Context; +use camino::Utf8Path; +use rustls::server::AllowAnyAuthenticatedClient; +use rustls::Certificate; +use rustls::PrivateKey; +use rustls::RootCertStore; +use rustls::ServerConfig; +use rustls_pemfile::Item; +use std::fs::File; +use std::io; +use std::sync::Arc; + +/// Read a directory into a RootCertStore +// TODO unit test me +pub fn read_trust_store(ca_dir: &Utf8Path) -> anyhow::Result { + let mut roots = RootCertStore::empty(); + + let mut ders = Vec::new(); + for file in ca_dir + .read_dir_utf8() + .with_context(|| format!("reading {ca_dir}"))? + { + let file = file.with_context(|| format!("reading metadata for file in {ca_dir}"))?; + let mut path = ca_dir.to_path_buf(); + path.push(file.file_name()); + + if path.is_dir() { + continue; + } + + let Ok(mut pem_file) = File::open(&path).map(std::io::BufReader::new) else { + continue; + }; + if let Some(value) = rustls_pemfile::certs(&mut pem_file) + .with_context(|| format!("reading {path}"))? + .into_iter() + .next() + { + ders.push(value); + }; + } + roots.add_parsable_certificates(&ders); + + Ok(roots) +} + +/// Load the SSL configuration for rustls +pub fn ssl_config( + certificate_chain: Vec>, + key_der: Vec, + root_certs: Option, +) -> anyhow::Result { + // Trusted CA for client certificates + let config = ServerConfig::builder().with_safe_defaults(); + + let config = if let Some(root_certs) = root_certs { + config.with_client_cert_verifier(Arc::new(AllowAnyAuthenticatedClient::new(root_certs))) + } else { + config.with_no_client_auth() + }; + + let server_cert = certificate_chain.into_iter().map(Certificate).collect(); + let server_key = PrivateKey(key_der); + + config + .with_single_cert(server_cert, server_key) + .context("invalid key or certificate") +} + +/// Load the server certificate +pub fn load_cert(filename: &Utf8Path) -> anyhow::Result>> { + let certfile = File::open(filename) + .with_context(|| format!("cannot open certificate file: {filename:?}"))?; + let mut reader = std::io::BufReader::new(certfile); + rustls_pemfile::certs(&mut reader) + .with_context(|| format!("parsing PEM-encoded certificate from {filename:?}")) +} + +/// Load the server private key +pub fn load_pkey(filename: &Utf8Path) -> anyhow::Result> { + let keyfile = + File::open(filename).with_context(|| format!("cannot open key file {filename:?}"))?; + let mut reader = std::io::BufReader::new(keyfile); + pkey_from_pem(&mut reader, filename) +} + +pub fn pkey_from_pem(reader: &mut dyn io::BufRead, filename: &Utf8Path) -> anyhow::Result> { + rustls_pemfile::read_one(reader) + .with_context(|| format!("reading PEM-encoded private key from {filename:?}"))? + .ok_or(anyhow!( + "expected private key in {filename:?}, but found no PEM-encoded data" + )) + .and_then(|item| match item { + Item::ECKey(key) | Item::PKCS8Key(key) | Item::RSAKey(key) => Ok(key), + Item::Crl(_) => Err(anyhow!("expected private key in {filename:?}, found a CRL")), + Item::X509Certificate(_) => Err(anyhow!( + "expected private key in {filename:?}, found an X509 certificate" + )), + _item => Err(anyhow!( + "expected private key in {filename:?}, found an unknown PEM-encoded item" + )), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use axum::routing::get; + use axum::Router; + use std::io::Cursor; + + mod read_trust_store { + use super::*; + use std::os::unix::fs::symlink; + use std::path::Path; + use tempfile::tempdir; + + macro_rules! tempdir_path { + ($dir:ident $(/ $file:literal)*) => ({ + let mut path = $dir.path().to_path_buf(); + $(path.push($file);)* + ::camino::Utf8PathBuf::try_from(path).unwrap() + }) + } + + #[test] + fn reads_certificates_in_provided_directory() { + let dir = tempdir().unwrap(); + + copy_test_file_to("ec.crt", tempdir_path!(dir / "cert.crt")).unwrap(); + + let store = read_trust_store(dir.path().try_into().unwrap()).unwrap(); + assert_eq!(store.len(), 1); + } + + #[test] + fn reads_multiple_certificates_from_directory() { + let dir = tempdir().unwrap(); + + copy_test_file_to("ec.crt", tempdir_path!(dir / "ec.crt")).unwrap(); + copy_test_file_to("rsa.crt", tempdir_path!(dir / "rsa.crt")).unwrap(); + + let store = read_trust_store(dir.path().try_into().unwrap()).unwrap(); + assert_eq!(store.len(), 2); + } + + #[test] + fn ignores_private_keys_in_provided_directory() { + let dir = tempdir().unwrap(); + copy_test_file_to("ec.key", tempdir_path!(dir / "example.key")).unwrap(); + + let store = read_trust_store(dir.path().try_into().unwrap()).unwrap(); + assert_eq!(store.len(), 0); + } + + #[test] + fn reads_certificates_via_relative_symlink() { + let dir = tempdir().unwrap(); + let cert_path = tempdir_path!(dir / "certs" / "cert.crt"); + let real_cert_path = tempdir_path!(dir / "actual_certs" / "cert.crt"); + + let trust_store = create_parent_dir(&cert_path).unwrap(); + create_parent_dir(&real_cert_path).unwrap(); + copy_test_file_to("ec.crt", real_cert_path).unwrap(); + symlink("../actual_certs/cert.crt", &cert_path).unwrap(); + + let store = read_trust_store(trust_store).unwrap(); + assert!(std::fs::symlink_metadata(&cert_path).unwrap().is_symlink()); + assert_eq!(store.len(), 1); + } + + #[test] + fn reads_certificates_via_absolute_symlink() { + let dir = tempdir().unwrap(); + let cert_path = tempdir_path!(dir / "certs" / "cert.crt"); + let real_cert_path = tempdir_path!(dir / "actual_certs" / "cert.crt"); + + let trust_store = create_parent_dir(&cert_path).unwrap(); + create_parent_dir(&real_cert_path).unwrap(); + copy_test_file_to("ec.crt", &real_cert_path).unwrap(); + symlink(&real_cert_path, &cert_path).unwrap(); + + let store = read_trust_store(trust_store).unwrap(); + assert!(std::fs::symlink_metadata(&cert_path).unwrap().is_symlink()); + assert_eq!(store.len(), 1); + } + + fn create_parent_dir(path: &Utf8Path) -> io::Result<&Utf8Path> { + let path = path.parent().expect("path should have parent"); + std::fs::create_dir(path)?; + Ok(path) + } + + fn copy_test_file_to(test_file: &str, path: impl AsRef) -> io::Result { + std::fs::copy(format!("./test_data/{test_file}"), path) + } + } + + #[test] + fn load_pkey_fails_when_given_x509_certificate() { + assert_eq!( + load_pkey(Utf8Path::new("./test_data/ec.crt")) + .unwrap_err() + .to_string(), + "expected private key in \"./test_data/ec.crt\", found an X509 certificate" + ); + } + + #[test] + fn load_pkey_fails_when_given_certificate_revocation_list() { + assert_eq!( + load_pkey(Utf8Path::new("./test_data/demo.crl")) + .unwrap_err() + .to_string(), + "expected private key in \"./test_data/demo.crl\", found a CRL" + ); + } + + mod server_accepts { + use super::*; + + #[tokio::test] + async fn alg_ed25519_pkcs8() { + let key = test_data("ed25519.key"); + let cert = test_data("ed25519.crt"); + + let (config, cert) = config_from_pem(&key, &cert).unwrap(); + + assert_matches!(parse_key_to_item(&key), Item::PKCS8Key(_)); + assert_server_works_with(config, cert).await; + } + + #[tokio::test] + async fn alg_ec() { + let key = test_data("ec.key"); + let cert = test_data("ec.crt"); + + let (config, cert) = config_from_pem(&key, &cert).unwrap(); + + assert_matches!(parse_key_to_item(&key), Item::ECKey(_)); + assert_server_works_with(config, cert).await; + } + + #[tokio::test] + async fn alg_ec_pkcs8() { + let key = test_data("ec.pkcs8.key"); + let cert = test_data("ec.crt"); + + let (config, cert) = config_from_pem(&key, &cert).unwrap(); + + assert_matches!(parse_key_to_item(&key), Item::PKCS8Key(_)); + assert_server_works_with(config, cert).await; + } + + #[tokio::test] + async fn alg_rsa_pkcs8() { + let key = test_data("rsa.pkcs8.key"); + let cert = test_data("rsa.crt"); + + let (config, cert) = config_from_pem(&key, &cert).unwrap(); + + assert_matches!(parse_key_to_item(&key), Item::PKCS8Key(_)); + assert_server_works_with(config, cert).await; + } + + #[tokio::test] + async fn alg_rsa_pkcs1() { + let key = test_data("rsa.pkcs1.key"); + let cert = test_data("rsa.crt"); + + let (config, cert) = config_from_pem(&key, &cert).unwrap(); + + assert_matches!(parse_key_to_item(&key), Item::RSAKey(_)); + assert_server_works_with(config, cert).await; + } + + fn parse_key_to_item(pem: &str) -> Item { + rustls_pemfile::read_one(&mut Cursor::new(pem)) + .unwrap() + .unwrap() + } + + fn test_data(file_name: &str) -> String { + std::fs::read_to_string(format!("./test_data/{file_name}")) + .with_context(|| format!("opening file {file_name} from test_data")) + .unwrap() + } + + fn config_from_pem( + key: &str, + cert: &str, + ) -> anyhow::Result<(ServerConfig, reqwest::tls::Certificate)> { + let chain = rustls_pemfile::certs(&mut Cursor::new(cert)).context("reading certs")?; + let key_der = parse_key_to_der(key)?; + let cert = reqwest::tls::Certificate::from_der( + chain.first().expect("chain should contain certificate"), + ) + .context("converting certificate to reqwest::tls::Certificate")?; + let config = ssl_config(chain, key_der, None)?; + + Ok((config, cert)) + } + + fn parse_key_to_der(pem: &str) -> anyhow::Result> { + pkey_from_pem( + &mut Cursor::new(pem), + Utf8Path::new("just-in-memory-not-a-file.pem"), + ) + .context("calling pkey_from_pem") + } + + async fn assert_server_works_with(config: ServerConfig, cert: reqwest::tls::Certificate) { + let (port, listener) = listener(); + let app = Router::new().route("/test", get(|| async { "it works!" })); + + let task = tokio::spawn(crate::start_tls_server(listener, config, app)); + let client = reqwest::Client::builder() + .add_root_certificate(cert) + .build() + .unwrap(); + assert_eq!( + client + .get(format!("https://localhost:{port}/test")) + .send() + .await + .unwrap() + .text() + .await + .unwrap(), + "it works!" + ); + task.abort(); + } + + fn listener() -> (u16, std::net::TcpListener) { + let mut port = 3500; + loop { + if let Ok(listener) = std::net::TcpListener::bind(format!("127.0.0.1:{port}")) { + break (port, listener); + } + port += 1; + } + } + } +} diff --git a/crates/common/axum_tls/src/lib.rs b/crates/common/axum_tls/src/lib.rs new file mode 100644 index 00000000000..9557235f9f3 --- /dev/null +++ b/crates/common/axum_tls/src/lib.rs @@ -0,0 +1,26 @@ +mod acceptor; +mod files; +mod maybe_tls; +mod redirect_http; + +use crate::acceptor::Acceptor; +pub use crate::acceptor::TlsData; +pub use crate::files::*; +use crate::redirect_http::redirect_http_to_https; +use axum::middleware::map_request; +use axum::Router; +use std::future::Future; +use std::net::TcpListener; + +pub fn start_tls_server( + listener: TcpListener, + server_config: rustls::ServerConfig, + app: Router, +) -> impl Future> { + axum_server::from_tcp(listener) + .acceptor(Acceptor::new(server_config)) + .serve( + app.layer(map_request(redirect_http_to_https)) + .into_make_service(), + ) +} diff --git a/crates/common/axum_tls/src/maybe_tls.rs b/crates/common/axum_tls/src/maybe_tls.rs new file mode 100644 index 00000000000..37eaac690ea --- /dev/null +++ b/crates/common/axum_tls/src/maybe_tls.rs @@ -0,0 +1,65 @@ +use pin_project::pin_project; +use std::io; +use std::io::Error; +use std::pin::Pin; +use std::task::Poll; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; +use tokio::io::BufReader; +use tokio::io::ReadBuf; +use tokio_rustls::server::TlsStream; + +#[pin_project(project = MaybeTlsStreamProj)] +/// An optional [TlsStream], i.e. a stream of either TLS or non-TLS data +/// +/// This is useful for redirecting HTTP requests to HTTPS. +pub enum MaybeTlsStream { + Tls(#[pin] Box>>), + Insecure(#[pin] BufReader), +} + +impl AsyncRead for MaybeTlsStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.project() { + MaybeTlsStreamProj::Tls(tls) => tls.poll_read(cx, buf), + MaybeTlsStreamProj::Insecure(insecure) => insecure.poll_read(cx, buf), + } + } +} + +impl AsyncWrite for MaybeTlsStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.project() { + MaybeTlsStreamProj::Tls(tls) => tls.poll_write(cx, buf), + MaybeTlsStreamProj::Insecure(insecure) => insecure.poll_write(cx, buf), + } + } + + fn poll_flush( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + match self.project() { + MaybeTlsStreamProj::Tls(tls) => tls.poll_flush(cx), + MaybeTlsStreamProj::Insecure(insecure) => insecure.poll_flush(cx), + } + } + + fn poll_shutdown( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + match self.project() { + MaybeTlsStreamProj::Tls(tls) => tls.poll_shutdown(cx), + MaybeTlsStreamProj::Insecure(insecure) => insecure.poll_shutdown(cx), + } + } +} diff --git a/crates/common/axum_tls/src/redirect_http.rs b/crates/common/axum_tls/src/redirect_http.rs new file mode 100644 index 00000000000..dcd5f75cefb --- /dev/null +++ b/crates/common/axum_tls/src/redirect_http.rs @@ -0,0 +1,74 @@ +use crate::acceptor::Acceptor; +use crate::acceptor::TlsData; +use axum::http::uri::Authority; +use axum::http::uri::InvalidUriParts; +use axum::http::uri::Scheme; +use axum::http::Request; +use axum::http::StatusCode; +use axum::http::Uri; +use axum::response::IntoResponse; +use axum::response::Redirect; +use axum::response::Response; +use tracing::error; + +pub async fn redirect_http_to_https( + request: Request, +) -> Result, HttpsRedirectResponse> { + let tls_data = request + .extensions() + .get::() + .ok_or(HttpsRedirectResponse::MissingTlsData)?; + + if tls_data.is_secure { + Ok(request) + } else { + let host = request + .headers() + .get("host") + .ok_or(HttpsRedirectResponse::MissingHostHeader)? + .to_owned(); + let mut uri = request.uri().to_owned().into_parts(); + uri.scheme = Some(Scheme::HTTPS); + uri.authority = Some(Authority::from_maybe_shared(host).unwrap()); + let uri = Uri::try_from(uri).map_err(HttpsRedirectResponse::FailedToCreateUri)?; + Err(HttpsRedirectResponse::RedirectTo(uri.to_string())) + } +} + +#[derive(Debug)] +pub enum HttpsRedirectResponse { + MissingTlsData, + MissingHostHeader, + FailedToCreateUri(InvalidUriParts), + RedirectTo(String), +} + +impl IntoResponse for HttpsRedirectResponse { + fn into_response(self) -> Response { + let internal_err = ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal error in tedge-mapper", + ); + match self { + Self::MissingTlsData => { + error!( + "{} not set. Are you using the right acceptor ({})?", + std::any::type_name::(), + std::any::type_name::() + ); + internal_err.into_response() + } + Self::FailedToCreateUri(e) => { + error!("{}", anyhow::Error::new(e).context("Failed to create URI")); + internal_err.into_response() + } + Self::MissingHostHeader => ( + StatusCode::BAD_REQUEST, + "This server does not support HTTP. Please retry this request using HTTPS", + ) + .into_response(), + Self::RedirectTo(uri) => Redirect::temporary(&uri).into_response(), + } + .into_response() + } +} diff --git a/crates/common/axum_tls/test_data/_regenerate_certs.sh b/crates/common/axum_tls/test_data/_regenerate_certs.sh new file mode 100755 index 00000000000..aac21b159a6 --- /dev/null +++ b/crates/common/axum_tls/test_data/_regenerate_certs.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# This script generates the certificates required for the "unit" +# tests in axum_tls using openssl + +days=365000 +args=("-days" "$days" "-noenc" \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,DNS:*.localhost" \ + -addext "basicConstraints=critical,CA:false") + +set -eux + +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout ec.pkcs8.key -out ec.crt "${args[@]}" +openssl req -x509 -newkey rsa -keyout rsa.pkcs8.key -out rsa.crt "${args[@]}" +openssl req -x509 -newkey ed25519 -keyout ed25519.key -out ed25519.crt "${args[@]}" + +openssl ec -in ec.pkcs8.key -out ec.key +openssl pkey -in rsa.pkcs8.key -out rsa.pkcs1.key -traditional \ No newline at end of file diff --git a/crates/common/axum_tls/test_data/demo.crl b/crates/common/axum_tls/test_data/demo.crl new file mode 100644 index 00000000000..86f441303f4 --- /dev/null +++ b/crates/common/axum_tls/test_data/demo.crl @@ -0,0 +1,19 @@ +-----BEGIN X509 CRL----- +MIIDFDCCAfwCAQEwDQYJKoZIhvcNAQEFBQAwXzEjMCEGA1UEChMaU2FtcGxlIFNp +Z25lciBPcmdhbml6YXRpb24xGzAZBgNVBAsTElNhbXBsZSBTaWduZXIgVW5pdDEb +MBkGA1UEAxMSU2FtcGxlIFNpZ25lciBDZXJ0Fw0xMzAyMTgxMDMyMDBaFw0xMzAy +MTgxMDQyMDBaMIIBNjA8AgMUeUcXDTEzMDIxODEwMjIxMlowJjAKBgNVHRUEAwoB +AzAYBgNVHRgEERgPMjAxMzAyMTgxMDIyMDBaMDwCAxR5SBcNMTMwMjE4MTAyMjIy +WjAmMAoGA1UdFQQDCgEGMBgGA1UdGAQRGA8yMDEzMDIxODEwMjIwMFowPAIDFHlJ +Fw0xMzAyMTgxMDIyMzJaMCYwCgYDVR0VBAMKAQQwGAYDVR0YBBEYDzIwMTMwMjE4 +MTAyMjAwWjA8AgMUeUoXDTEzMDIxODEwMjI0MlowJjAKBgNVHRUEAwoBATAYBgNV +HRgEERgPMjAxMzAyMTgxMDIyMDBaMDwCAxR5SxcNMTMwMjE4MTAyMjUxWjAmMAoG +A1UdFQQDCgEFMBgGA1UdGAQRGA8yMDEzMDIxODEwMjIwMFqgLzAtMB8GA1UdIwQY +MBaAFL4SAcyq6hGA2i6tsurHtfuf+a00MAoGA1UdFAQDAgEDMA0GCSqGSIb3DQEB +BQUAA4IBAQBCIb6B8cN5dmZbziETimiotDy+FsOvS93LeDWSkNjXTG/+bGgnrm3a +QpgB7heT8L2o7s2QtjX2DaTOSYL3nZ/Ibn/R8S0g+EbNQxdk5/la6CERxiRp+E2T +UG8LDb14YVMhRGKvCguSIyUG0MwGW6waqVtd6K71u7vhIU/Tidf6ZSdsTMhpPPFu +PUid4j29U3q10SGFF6cCt1DzjvUcCwHGhHA02Men70EgZFADPLWmLg0HglKUh1iZ +WcBGtev/8VsUijyjsM072C6Ut5TwNyrrthb952+eKlmxLNgT0o5hVYxjXhtwLQsL +7QZhrypAM1DLYqQjkiDI7hlvt7QuDGTJ +-----END X509 CRL----- \ No newline at end of file diff --git a/crates/common/axum_tls/test_data/ec.crt b/crates/common/axum_tls/test_data/ec.crt new file mode 100644 index 00000000000..5a2587e3e1b --- /dev/null +++ b/crates/common/axum_tls/test_data/ec.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBnzCCAUWgAwIBAgIUSTUtJUfUdERMKBwsfdRv9IbvQicwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTIzMTExNDE2MDUwOVoYDzMwMjMwMzE3 +MTYwNTA5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAAR2SVEPD34AAxFuk0xYm60p7hA7+1SW+sFHazBRg32ifFd0o2Mn+Tf+ +voYflBi3v4lhr361RoWB8QfmaGN05vv+o3MwcTAdBgNVHQ4EFgQUAb4jQ7RQ/xyg +cZM+We8ik29/oxswHwYDVR0jBBgwFoAUAb4jQ7RQ/xygcZM+We8ik29/oxswIQYD +VR0RBBowGIIJbG9jYWxob3N0ggsqLmxvY2FsaG9zdDAMBgNVHRMBAf8EAjAAMAoG +CCqGSM49BAMCA0gAMEUCIA6QrxoDHQJqoly7d8VN0sj0eDvfFpbbZdSnzBd6R8AP +AiEAm/PAH3IPGuHRBIpdC0rNR8F/l3WcN9I9984qKZdG5rs= +-----END CERTIFICATE----- diff --git a/crates/common/axum_tls/test_data/ec.key b/crates/common/axum_tls/test_data/ec.key new file mode 100644 index 00000000000..02f6d590f30 --- /dev/null +++ b/crates/common/axum_tls/test_data/ec.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIBX2Z/NKGEX14QbH4kb5GXom0pqSPfX0mxdWbLb86apEoAoGCCqGSM49 +AwEHoUQDQgAEdklRDw9+AAMRbpNMWJutKe4QO/tUlvrBR2swUYN9onxXdKNjJ/k3 +/r6GH5QYt7+JYa9+tUaFgfEH5mhjdOb7/g== +-----END EC PRIVATE KEY----- diff --git a/crates/common/axum_tls/test_data/ec.pkcs8.key b/crates/common/axum_tls/test_data/ec.pkcs8.key new file mode 100644 index 00000000000..7eddd21e8db --- /dev/null +++ b/crates/common/axum_tls/test_data/ec.pkcs8.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgFfZn80oYRfXhBsfi +RvkZeibSmpI99fSbF1ZstvzpqkShRANCAAR2SVEPD34AAxFuk0xYm60p7hA7+1SW ++sFHazBRg32ifFd0o2Mn+Tf+voYflBi3v4lhr361RoWB8QfmaGN05vv+ +-----END PRIVATE KEY----- diff --git a/crates/common/axum_tls/test_data/ed25519.crt b/crates/common/axum_tls/test_data/ed25519.crt new file mode 100644 index 00000000000..fc162920169 --- /dev/null +++ b/crates/common/axum_tls/test_data/ed25519.crt @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBXzCCARGgAwIBAgIUMTdemw1ehDhI74y1G3RVggvgS+kwBQYDK2VwMBQxEjAQ +BgNVBAMMCWxvY2FsaG9zdDAgFw0yMzExMTQxNjA1MTBaGA8zMDIzMDMxNzE2MDUx +MFowFDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEA/UW75ceWTm5/gUFx +s8E8V9hwunGiS3POOaOFRL1fsomjczBxMB0GA1UdDgQWBBT0bcj2U4AWeGQY6SNU +0VXdEcnjUDAfBgNVHSMEGDAWgBT0bcj2U4AWeGQY6SNU0VXdEcnjUDAhBgNVHREE +GjAYgglsb2NhbGhvc3SCCyoubG9jYWxob3N0MAwGA1UdEwEB/wQCMAAwBQYDK2Vw +A0EAw0W+9MuZ/yVpjgdBEYtDbgU41ESa4WwSwN9mLHcTtBrcFKhmHRe7zxoV50SB +hl/lsQ2UVNAAKA1xb5teTSN4AA== +-----END CERTIFICATE----- diff --git a/crates/common/axum_tls/test_data/ed25519.key b/crates/common/axum_tls/test_data/ed25519.key new file mode 100644 index 00000000000..2b584a54949 --- /dev/null +++ b/crates/common/axum_tls/test_data/ed25519.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIHk0JP05MzYNxrJz86L9EZfdP9Etbo0qpFWfWGsHMqJz +-----END PRIVATE KEY----- diff --git a/crates/common/axum_tls/test_data/rsa.crt b/crates/common/axum_tls/test_data/rsa.crt new file mode 100644 index 00000000000..2a527d7624d --- /dev/null +++ b/crates/common/axum_tls/test_data/rsa.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKzCCAhOgAwIBAgIUYJV5nCxWPleHI2VvUR0N/PXW2AEwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTIzMTExNDE2MDUxMFoYDzMwMjMw +MzE3MTYwNTEwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCl4CGc5yQkcZ8zU5oeovsITD+J/oJiNQjsXAwNHZM4 +3vy4rrRlVCAHULMAecKfNwuUocETZVgU/XPKrTXhuNO+v0GmIF/bbCBk8oLRpzFI +ywCgaOzybzUzeF//80neA3O2Tiplb/Y5NzK/ECvn0oZLqHrdBJcsFmskpbBn7Fep +MVz4g5b7bHNim3f5PKwODRrMLTSpqKkVNmYsBTGfdRnrRM3X0EBmt3f9Zm1Vk2uI +F9AamjvvIB1JbSR9HSGP3kJqKOQoeyWvwa001JqwK25DRGZiOQcMxkZOpE5j03IL +6lfDFwsmGOC7cFWaN3O6XO/GdtSMU0Rq1Kr+d43TVdxNAgMBAAGjczBxMB0GA1Ud +DgQWBBRNCvr3snQQYV1CeBoefp97nOcq+jAfBgNVHSMEGDAWgBRNCvr3snQQYV1C +eBoefp97nOcq+jAhBgNVHREEGjAYgglsb2NhbGhvc3SCCyoubG9jYWxob3N0MAwG +A1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggEBAB6mrO8ZbhKGR98m0NyzE2vK +PeQ8yasnUbLOf538afIlXNCEvKLQuQbYf2GlbT4fxWfimixf/iKkABYyTLGlTFMP +sM7+ZEhtALTO++S4eJJNF4+ZNE0WsBYQ5ZCv46AAYROipRYkUlVO6XGnggTBPeez +eQGImkni393b498P0qqH/PvFhwgH/T62N5kgKwhGcDuldYwmCqUYpvzWvFmUiC+x +BbrhE70ZSLsKQjglFT4Vx8bgqPQLRUgaku6SXBNcWhRf5vvED9PGBycZ3jOsLQc3 +9q9piK+zSS/5AEwkTpljDg08n6AkfT8ODpHfa2XsoDf/qiVb7iTAMD+In+fIPy8= +-----END CERTIFICATE----- diff --git a/crates/common/axum_tls/test_data/rsa.pkcs1.key b/crates/common/axum_tls/test_data/rsa.pkcs1.key new file mode 100644 index 00000000000..0d884db228c --- /dev/null +++ b/crates/common/axum_tls/test_data/rsa.pkcs1.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEApeAhnOckJHGfM1OaHqL7CEw/if6CYjUI7FwMDR2TON78uK60 +ZVQgB1CzAHnCnzcLlKHBE2VYFP1zyq014bjTvr9BpiBf22wgZPKC0acxSMsAoGjs +8m81M3hf//NJ3gNztk4qZW/2OTcyvxAr59KGS6h63QSXLBZrJKWwZ+xXqTFc+IOW ++2xzYpt3+TysDg0azC00qaipFTZmLAUxn3UZ60TN19BAZrd3/WZtVZNriBfQGpo7 +7yAdSW0kfR0hj95CaijkKHslr8GtNNSasCtuQ0RmYjkHDMZGTqROY9NyC+pXwxcL +Jhjgu3BVmjdzulzvxnbUjFNEatSq/neN01XcTQIDAQABAoIBAAULC9clWlVrdsuc +u44nr5fUBxDydDxwEpChY1/7bAhHpnLd/32VnQL2NgpzRK4TndLXNSXRNbp4NzyR +A83mmlnEeljPx4bhfb2lHAtQQfepJBdX3MHOVfoRT+NQ3lFjFPsm/+FXkeqfMq70 +7TuplYec1+cokdIyyrij1oveUtgdK9CBXeOhdrTbGGQSvFKhijZnh+mRAe+N5hQ1 +Cbuak5F8IoBKbA+BC6c8c/EWPv5tPpzTcCifG9nE7OIwbDmAYg73ljLbhtran/KC +5/K0JHuEL6JjtztfcMZUmWUdljTZVbHMLAXVVK2SUshfQE+FLdSh7/ygH8hAkEtB +8kOqbNECgYEA0VXhUMfPKfd3kxbbMgsEX0rgv31JM1Xm0q0MNHFk24ShpzrohPZT +TzO+n0Yp3kZUMiTg3RPNmPmjI87hXROnpv6CHazxm+Na9b7zzLsKbRNZU0FBBcmi +f1y9UO8Eg4HONBsxNwr4yZFW5REYTOTdpnXJ2mjPVhznk7YL583nvTECgYEAytog +pI1BxnYgs2vF+YbLMUg0Qh4lF+A7Zso0jTEbcyglsUB6iNdi1NTbNLuwOTaz3l/E +q2ZgTxv7IF5C8ifX8ALq3KcGsPog9/ebMTP505tlsZItsG2YBd/OtxYOmjzXsga5 +Pwz6s5UasVCN4dQbs6pR6BDFR3ZNYWIrCfM52d0CgYANj/jXGPrtByFyICr3ZQtV +eS5yeZWCg/A+egOuaiJUrpUiloh2BNeE7B9PhmY0Bm5yCT2gVSYe4R2WtlKXiyxz +f03Cym+k3+gGv+Zfv0Z/pp9E65dg3p1ujv2c/r9WHdTUP2bC4C0aMhZlJORkJvfN +TxhS1DOKqri+My82R3raIQKBgQDAOq7+YOI5CQ56GKJ2kRcS76KeGWT7WEHSacId +HrEtkpkNfNXhwYJlwASu10HrJfyTudtstcqEjTaQeOMmCT3ns0wPp7R+l7oQYjNO +EDwqHDPlb2oeq+yJfIqvE5bo8MlSam634jvdXGn8KCMcI13RB5EwwlvBGcnAhD/W +4QgsfQKBgBHpjB1XubP4SbjWQe64zES2cjWMskbCe+lBgVXs5CC42w1VoX5m3BdP +akWTNbMQqo/lB+htUJKrLQqdFCKUVeXG7GAk7xo0zynKSP8HyJLjmGBNzB1CTg1j +UCUPiJtM1ymPwQ18y9GEREjISg2ZDm4WIxBMzFI4PrNplUp9Uc7W +-----END RSA PRIVATE KEY----- diff --git a/crates/common/axum_tls/test_data/rsa.pkcs8.key b/crates/common/axum_tls/test_data/rsa.pkcs8.key new file mode 100644 index 00000000000..a2656da78a8 --- /dev/null +++ b/crates/common/axum_tls/test_data/rsa.pkcs8.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCl4CGc5yQkcZ8z +U5oeovsITD+J/oJiNQjsXAwNHZM43vy4rrRlVCAHULMAecKfNwuUocETZVgU/XPK +rTXhuNO+v0GmIF/bbCBk8oLRpzFIywCgaOzybzUzeF//80neA3O2Tiplb/Y5NzK/ +ECvn0oZLqHrdBJcsFmskpbBn7FepMVz4g5b7bHNim3f5PKwODRrMLTSpqKkVNmYs +BTGfdRnrRM3X0EBmt3f9Zm1Vk2uIF9AamjvvIB1JbSR9HSGP3kJqKOQoeyWvwa00 +1JqwK25DRGZiOQcMxkZOpE5j03IL6lfDFwsmGOC7cFWaN3O6XO/GdtSMU0Rq1Kr+ +d43TVdxNAgMBAAECggEABQsL1yVaVWt2y5y7jievl9QHEPJ0PHASkKFjX/tsCEem +ct3/fZWdAvY2CnNErhOd0tc1JdE1ung3PJEDzeaaWcR6WM/HhuF9vaUcC1BB96kk +F1fcwc5V+hFP41DeUWMU+yb/4VeR6p8yrvTtO6mVh5zX5yiR0jLKuKPWi95S2B0r +0IFd46F2tNsYZBK8UqGKNmeH6ZEB743mFDUJu5qTkXwigEpsD4ELpzxz8RY+/m0+ +nNNwKJ8b2cTs4jBsOYBiDveWMtuG2tqf8oLn8rQke4QvomO3O19wxlSZZR2WNNlV +scwsBdVUrZJSyF9AT4Ut1KHv/KAfyECQS0HyQ6ps0QKBgQDRVeFQx88p93eTFtsy +CwRfSuC/fUkzVebSrQw0cWTbhKGnOuiE9lNPM76fRineRlQyJODdE82Y+aMjzuFd +E6em/oIdrPGb41r1vvPMuwptE1lTQUEFyaJ/XL1Q7wSDgc40GzE3CvjJkVblERhM +5N2mdcnaaM9WHOeTtgvnzee9MQKBgQDK2iCkjUHGdiCza8X5hssxSDRCHiUX4Dtm +yjSNMRtzKCWxQHqI12LU1Ns0u7A5NrPeX8SrZmBPG/sgXkLyJ9fwAurcpwaw+iD3 +95sxM/nTm2Wxki2wbZgF3863Fg6aPNeyBrk/DPqzlRqxUI3h1BuzqlHoEMVHdk1h +YisJ8znZ3QKBgA2P+NcY+u0HIXIgKvdlC1V5LnJ5lYKD8D56A65qIlSulSKWiHYE +14TsH0+GZjQGbnIJPaBVJh7hHZa2UpeLLHN/TcLKb6Tf6Aa/5l+/Rn+mn0Trl2De +nW6O/Zz+v1Yd1NQ/ZsLgLRoyFmUk5GQm981PGFLUM4qquL4zLzZHetohAoGBAMA6 +rv5g4jkJDnoYonaRFxLvop4ZZPtYQdJpwh0esS2SmQ181eHBgmXABK7XQesl/JO5 +22y1yoSNNpB44yYJPeezTA+ntH6XuhBiM04QPCocM+Vvah6r7Il8iq8TlujwyVJq +brfiO91cafwoIxwjXdEHkTDCW8EZycCEP9bhCCx9AoGAEemMHVe5s/hJuNZB7rjM +RLZyNYyyRsJ76UGBVezkILjbDVWhfmbcF09qRZM1sxCqj+UH6G1QkqstCp0UIpRV +5cbsYCTvGjTPKcpI/wfIkuOYYE3MHUJODWNQJQ+Im0zXKY/BDXzL0YRESMhKDZkO +bhYjEEzMUjg+s2mVSn1RztY= +-----END PRIVATE KEY----- diff --git a/crates/common/download/Cargo.toml b/crates/common/download/Cargo.toml index c36834d2619..9e682351a39 100644 --- a/crates/common/download/Cargo.toml +++ b/crates/common/download/Cargo.toml @@ -12,11 +12,13 @@ repository = { workspace = true } [dependencies] anyhow = { workspace = true, features = ["backtrace"] } backoff = { workspace = true } +hyper = { workspace = true } log = { workspace = true } nix = { workspace = true } reqwest = { workspace = true, features = [ "rustls-tls-native-roots", ] } +rustls = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tedge_utils = { workspace = true } diff --git a/crates/common/download/examples/simple_download.rs b/crates/common/download/examples/simple_download.rs index 0b6184cba30..cff1ad89d12 100644 --- a/crates/common/download/examples/simple_download.rs +++ b/crates/common/download/examples/simple_download.rs @@ -12,7 +12,7 @@ async fn main() -> Result<()> { // Create downloader instance with desired file path and target directory. #[allow(deprecated)] - let downloader = Downloader::new("/tmp/test_download".into()); + let downloader = Downloader::new("/tmp/test_download".into(), None); // Call `download` method to get data from url. downloader.download(&url_data).await?; diff --git a/crates/common/download/src/download.rs b/crates/common/download/src/download.rs index 16f88dee93e..7401a97641b 100644 --- a/crates/common/download/src/download.rs +++ b/crates/common/download/src/download.rs @@ -10,8 +10,10 @@ use log::warn; use nix::sys::statvfs; pub use partial_response::InvalidResponseError; use reqwest::header; +use reqwest::Identity; use serde::Deserialize; use serde::Serialize; +use std::error::Error; use std::fs; use std::fs::File; use std::io::Seek; @@ -100,36 +102,33 @@ pub struct Downloader { target_filename: PathBuf, target_permission: PermissionEntry, backoff: ExponentialBackoff, -} - -impl From for Downloader { - fn from(path: PathBuf) -> Self { - Self { - target_filename: path, - target_permission: PermissionEntry::default(), - backoff: default_backoff(), - } - } + identity: Option, } impl Downloader { /// Creates a new downloader which downloads to a target directory and uses /// default permissions. - pub fn new(target_path: PathBuf) -> Self { + pub fn new(target_path: PathBuf, identity: Option) -> Self { Self { target_filename: target_path, target_permission: PermissionEntry::default(), backoff: default_backoff(), + identity, } } /// Creates a new downloader which downloads to a target directory and sets /// specified permissions the downloaded file. - pub fn with_permission(target_path: PathBuf, target_permission: PermissionEntry) -> Self { + pub fn with_permission( + target_path: PathBuf, + target_permission: PermissionEntry, + identity: Option, + ) -> Self { Self { target_filename: target_path, target_permission, backoff: default_backoff(), + identity, } } @@ -386,20 +385,26 @@ impl Downloader { let backoff = self.backoff.clone(); let operation = || async { - let mut client = reqwest::Client::new().get(url.url()); + let mut client = reqwest::Client::builder(); + if let Some(identity) = &self.identity { + client = client.identity(identity.clone()); + } + let mut request = client.build()?.get(url.url()); if let Some(Auth::Bearer(token)) = &url.auth { - client = client.bearer_auth(token) + request = request.bearer_auth(token) } if range_start != 0 { - client = client.header("Range", format!("bytes={range_start}-")); + request = request.header("Range", format!("bytes={range_start}-")); } - client + request .send() .await .map_err(|err| { - if err.is_builder() || err.is_connect() { + // rustls errors are caused by e.g. CertificateRequired + // If this is the case, retrying won't help us + if err.is_builder() || err.is_connect() || is_rustls(&err) { backoff::Error::Permanent(err) } else { backoff::Error::transient(err) @@ -476,6 +481,18 @@ fn try_pre_allocate_space(file: &File, path: &Path, file_len: u64) -> Result<(), Ok(()) } +fn is_rustls(err: &reqwest::Error) -> bool { + (|| { + err.source()? + .downcast_ref::()? + .source()? + .downcast_ref::()? + .get_ref()? + .downcast_ref::() + })() + .is_some() +} + #[cfg(test)] #[allow(deprecated)] mod tests { @@ -508,7 +525,7 @@ mod tests { let url = DownloadInfo::new(&target_url); - let mut downloader = Downloader::new(target_path); + let mut downloader = Downloader::new(target_path, None); downloader.set_backoff(ExponentialBackoff { current_interval: Duration::ZERO, ..Default::default() @@ -538,7 +555,7 @@ mod tests { let url = DownloadInfo::new(&target_url); - let downloader = Downloader::new(target_path.clone()); + let downloader = Downloader::new(target_path.clone(), None); downloader.download(&url).await.unwrap(); let file_content = std::fs::read(target_path).unwrap(); @@ -567,7 +584,7 @@ mod tests { let url = DownloadInfo::new(&target_url); - let downloader = Downloader::new(target_path); + let downloader = Downloader::new(target_path, None); let err = downloader.download(&url).await.unwrap_err(); assert!(matches!(err, DownloadError::InsufficientSpace)); } @@ -590,7 +607,7 @@ mod tests { let url = DownloadInfo::new(&target_url); // empty filename - let downloader = Downloader::new("".into()); + let downloader = Downloader::new("".into(), None); let err = downloader.download(&url).await.unwrap_err(); assert!(matches!( err, @@ -599,7 +616,7 @@ mod tests { // invalid unicode filename let path = unsafe { String::from_utf8_unchecked(b"\xff".to_vec()) }; - let downloader = Downloader::new(path.into()); + let downloader = Downloader::new(path.into(), None); let err = downloader.download(&url).await.unwrap_err(); assert!(matches!( err, @@ -607,7 +624,7 @@ mod tests { )); // relative path filename - let downloader = Downloader::new("myfile.txt".into()); + let downloader = Downloader::new("myfile.txt".into(), None); let err = downloader.download(&url).await.unwrap_err(); assert!(matches!( err, @@ -634,7 +651,7 @@ mod tests { let url = DownloadInfo::new(&target_url); - let downloader = Downloader::new(target_file_path.clone()); + let downloader = Downloader::new(target_file_path.clone(), None); downloader.download(&url).await.unwrap(); let file_content = std::fs::read(target_file_path).unwrap(); @@ -661,7 +678,7 @@ mod tests { let url = DownloadInfo::new(&target_url); - let downloader = Downloader::new(target_path); + let downloader = Downloader::new(target_path, None); downloader.download(&url).await.unwrap(); @@ -688,7 +705,7 @@ mod tests { let url = DownloadInfo::new(&target_url); - let downloader = Downloader::new(target_path); + let downloader = Downloader::new(target_path, None); downloader.download(&url).await.unwrap(); let log_content = std::fs::read(downloader.filename()).unwrap(); @@ -709,7 +726,7 @@ mod tests { let url = DownloadInfo::new(&target_url); - let downloader = Downloader::new(target_path); + let downloader = Downloader::new(target_path, None); downloader.download(&url).await.unwrap(); assert_eq!("".as_bytes(), std::fs::read(downloader.filename()).unwrap()); @@ -799,7 +816,7 @@ mod tests { let tmpdir = TempDir::new().unwrap(); let target_path = tmpdir.path().join("partial_download"); - let downloader = Downloader::new(target_path); + let downloader = Downloader::new(target_path, None); let url = DownloadInfo::new(&format!("http://localhost:{port}/")); downloader.download(&url).await.unwrap(); @@ -907,7 +924,7 @@ mod tests { }; let target_path = target_dir_path.path().join("test_download"); - let mut downloader = Downloader::new(target_path); + let mut downloader = Downloader::new(target_path, None); downloader.set_backoff(ExponentialBackoff { current_interval: Duration::ZERO, ..Default::default() diff --git a/crates/common/download/src/lib.rs b/crates/common/download/src/lib.rs index 15a62f9a5e4..62f45c5d08f 100644 --- a/crates/common/download/src/lib.rs +++ b/crates/common/download/src/lib.rs @@ -30,7 +30,7 @@ //! ); //! //! // Create downloader instance with desired file path and target directory. -//! let downloader = Downloader::new("/tmp/test_download".into()); +//! let downloader = Downloader::new("/tmp/test_download".into(), None); //! //! // Call `download` method to get data from url. //! downloader.download(&url_data).await?; diff --git a/crates/common/tedge_config/Cargo.toml b/crates/common/tedge_config/Cargo.toml index f22a3e5a5e0..1e05df9cad9 100644 --- a/crates/common/tedge_config/Cargo.toml +++ b/crates/common/tedge_config/Cargo.toml @@ -9,12 +9,14 @@ homepage = { workspace = true } repository = { workspace = true } [dependencies] +anyhow = { workspace = true } camino = { workspace = true, features = ["serde", "serde1"] } certificate = { workspace = true } doku = { workspace = true } figment = { workspace = true, features = ["env", "toml"] } mqtt_channel = { workspace = true } once_cell = { workspace = true } +reqwest = { workspace = true, features = ["rustls-tls-native-roots"] } serde = { workspace = true, features = ["rc"] } serde_ignored = { workspace = true } strum_macros = { workspace = true } @@ -27,7 +29,6 @@ tracing-subscriber = { workspace = true } url = { workspace = true } [dev-dependencies] -anyhow = { workspace = true } assert_matches = { workspace = true } figment = { workspace = true, features = ["test"] } tedge_test_utils = { workspace = true } diff --git a/crates/common/tedge_config/src/lib.rs b/crates/common/tedge_config/src/lib.rs index a0513f9c1f8..761d5c13185 100644 --- a/crates/common/tedge_config/src/lib.rs +++ b/crates/common/tedge_config/src/lib.rs @@ -11,6 +11,7 @@ pub use self::tedge_config_cli::tedge_config_repository::*; pub use camino::Utf8Path as Path; pub use camino::Utf8PathBuf as PathBuf; pub use certificate::CertificateError; +pub use tedge_config_macros::all_or_nothing; /// loads the new tedge config from system default pub fn get_new_tedge_config() -> Result { diff --git a/crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs b/crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs index 5d3fcd83637..eab3cfecb54 100644 --- a/crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs +++ b/crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs @@ -6,12 +6,15 @@ use crate::TEdgeConfigLocation; use crate::TemplatesSet; use crate::HTTPS_PORT; use crate::MQTT_TLS_PORT; +use anyhow::anyhow; +use anyhow::Context; use camino::Utf8PathBuf; use certificate::CertificateError; use certificate::PemCertificate; use doku::Document; use once_cell::sync::Lazy; use std::borrow::Cow; +use std::io::Read; use std::net::IpAddr; use std::net::Ipv4Addr; use std::num::NonZeroU16; @@ -409,7 +412,23 @@ define_tedge_config! { /// Cumulocity mapper. #[tedge_config(example = "8001", default(value = 8001u16))] port: u16, - } + }, + + /// The file that will be used as a the server certificate for the Cumulocity proxy + #[tedge_config(example = "/etc/tedge/device-certs/c8y_proxy_certificate.pem")] + #[doku(as = "PathBuf")] + cert_path: Utf8PathBuf, + + /// The file that will be used as a the server private key for the Cumulocity proxy + #[tedge_config(example = "/etc/tedge/device-certs/c8y_proxy_key.pem")] + #[doku(as = "PathBuf")] + key_path: Utf8PathBuf, + + /// Path to a file containing the PEM encoded CA certificates that are + /// trusted when checking incoming client certificates for the Cumulocity Proxy + #[tedge_config(example = "/etc/ssl/certs")] + #[doku(as = "PathBuf")] + ca_path: Utf8PathBuf, }, bridge: { @@ -592,6 +611,18 @@ define_tedge_config! { #[tedge_config(default(value = "127.0.0.1"))] #[tedge_config(example = "127.0.0.1", example = "192.168.1.2", example = "tedge-hostname")] host: Arc, + + auth: { + /// Path to the certificate which is used by the agent when connecting to external services + #[doku(as = "PathBuf")] + #[tedge_config(reader(private))] + cert_file: Utf8PathBuf, + + /// Path to the private key which is used by the agent when connecting to external services + #[doku(as = "PathBuf")] + #[tedge_config(reader(private))] + key_file: Utf8PathBuf, + } } }, @@ -797,6 +828,33 @@ pub struct MqttAuthClientConfig { pub key_file: Utf8PathBuf, } +impl TEdgeConfigReaderHttpClientAuth { + pub fn identity(&self) -> anyhow::Result> { + use ReadableKey::*; + + let client_cert_key = + crate::all_or_nothing((self.cert_file.as_ref(), self.key_file.as_ref())) + .map_err(|e| anyhow!("{e}"))?; + + Ok(match client_cert_key { + Some((cert, key)) => { + let mut pem = std::fs::read(key).with_context(|| { + format!("reading private key (from {HttpClientAuthKeyFile}): {key}") + })?; + let mut cert_file = std::fs::File::open(cert).with_context(|| { + format!("opening certificate (from {HttpClientAuthCertFile}): {cert}") + })?; + cert_file.read_to_end(&mut pem).with_context(|| { + format!("reading certificate (from {HttpClientAuthCertFile}): {cert}") + })?; + + Some(reqwest::Identity::from_pem(&pem)?) + } + None => None, + }) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/core/plugin_sm/Cargo.toml b/crates/core/plugin_sm/Cargo.toml index 1842c86d798..e8973876bf6 100644 --- a/crates/core/plugin_sm/Cargo.toml +++ b/crates/core/plugin_sm/Cargo.toml @@ -9,10 +9,12 @@ homepage = { workspace = true } repository = { workspace = true } [dependencies] +anyhow = { workspace = true } async-trait = { workspace = true } csv = { workspace = true } download = { workspace = true } logged_command = { workspace = true } +reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tedge_api = { workspace = true } diff --git a/crates/core/plugin_sm/src/plugin.rs b/crates/core/plugin_sm/src/plugin.rs index c9e46b54fab..36d7ea387d1 100644 --- a/crates/core/plugin_sm/src/plugin.rs +++ b/crates/core/plugin_sm/src/plugin.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use csv::ReaderBuilder; use download::Downloader; use logged_command::LoggedCommand; +use reqwest::Identity; use serde::Deserialize; use std::error::Error; use std::path::Path; @@ -58,8 +59,14 @@ pub trait Plugin { let module_url = module.url.clone(); match module_url { Some(url) => { - self.install_from_url(&mut module, &url, logger, download_path) - .await? + self.install_from_url( + &mut module, + &url, + logger, + download_path, + self.identity(), + ) + .await? } None => self.install(&module, logger).await?, } @@ -70,6 +77,8 @@ pub trait Plugin { } } + fn identity(&self) -> Option<&Identity>; + async fn apply_all( &self, mut updates: Vec, @@ -93,7 +102,9 @@ pub trait Plugin { }; let module_url = module.url.clone(); if let Some(url) = module_url { - match Self::download_from_url(module, &url, logger, download_path).await { + match Self::download_from_url(module, &url, logger, download_path, self.identity()) + .await + { Err(prepare_error) => { failed_updates.push(prepare_error); break; @@ -139,8 +150,10 @@ pub trait Plugin { url: &DownloadInfo, logger: &mut BufWriter, download_path: &Path, + identity: Option<&Identity>, ) -> Result<(), SoftwareError> { - let downloader = Self::download_from_url(module, url, logger, download_path).await?; + let downloader = + Self::download_from_url(module, url, logger, download_path, identity).await?; let result = self.install(module, logger).await; Self::cleanup_downloaded_artefacts(downloader, logger).await?; @@ -152,9 +165,10 @@ pub trait Plugin { url: &DownloadInfo, logger: &mut BufWriter, download_path: &Path, + identity: Option<&Identity>, ) -> Result { let sm_path = sm_path(&module.name, &module.version, download_path); - let downloader = Downloader::new(sm_path); + let downloader = Downloader::new(sm_path, identity.map(|id| id.to_owned())); logger .write_all( @@ -223,6 +237,7 @@ pub struct ExternalPluginCommand { pub path: PathBuf, pub sudo: Option, pub max_packages: u32, + identity: Option, } impl ExternalPluginCommand { @@ -231,12 +246,14 @@ impl ExternalPluginCommand { path: impl Into, sudo: Option, max_packages: u32, + identity: Option, ) -> ExternalPluginCommand { ExternalPluginCommand { name: name.into(), path: path.into(), sudo, max_packages, + identity, } } @@ -498,6 +515,10 @@ impl Plugin for ExternalPluginCommand { }) } } + + fn identity(&self) -> Option<&Identity> { + self.identity.as_ref() + } } pub fn deserialize_module_info( diff --git a/crates/core/plugin_sm/src/plugin_manager.rs b/crates/core/plugin_sm/src/plugin_manager.rs index daea63e48d0..fb88659b91b 100644 --- a/crates/core/plugin_sm/src/plugin_manager.rs +++ b/crates/core/plugin_sm/src/plugin_manager.rs @@ -108,8 +108,7 @@ impl ExternalPlugins { }; if let Err(e) = plugins.load() { warn!( - "Reading the plugins directory: failed with: {:?}: {:?}", - e.kind(), + "Reading the plugins directory ({:?}): failed with: {e:?}", &plugins.plugin_dir ); return Ok(plugins); @@ -136,7 +135,7 @@ impl ExternalPlugins { Ok(plugins) } - pub fn load(&mut self) -> io::Result<()> { + pub fn load(&mut self) -> anyhow::Result<()> { self.plugin_map.clear(); let config = tedge_config::TEdgeConfigRepository::new(self.config_location.clone()) @@ -201,11 +200,13 @@ impl ExternalPlugins { if let Some(file_name) = path.file_name() { if let Some(plugin_name) = file_name.to_str() { + let identity = config.http.client.auth.identity()?; let plugin = ExternalPluginCommand::new( plugin_name, &path, self.sudo.clone(), config.software.plugin.max_packages, + identity, ); self.plugin_map.insert(plugin_name.into(), plugin); } diff --git a/crates/core/plugin_sm/tests/plugin.rs b/crates/core/plugin_sm/tests/plugin.rs index 80f84a27efc..ab55c07cd64 100644 --- a/crates/core/plugin_sm/tests/plugin.rs +++ b/crates/core/plugin_sm/tests/plugin.rs @@ -239,6 +239,7 @@ mod tests { &dummy_plugin_path, None, config.software.plugin.max_packages, + config.http.client.auth.identity()?, ); assert_eq!(plugin.name, "test"); assert_eq!(plugin.path, dummy_plugin_path); @@ -251,7 +252,7 @@ mod tests { fn plugin_check_module_type_both_same() { let dummy_plugin_path = get_dummy_plugin_path(); - let plugin = ExternalPluginCommand::new("test", dummy_plugin_path, None, 100); + let plugin = ExternalPluginCommand::new("test", dummy_plugin_path, None, 100, None); let module = SoftwareModule { module_type: Some("test".into()), @@ -275,7 +276,7 @@ mod tests { let dummy_plugin_path = get_dummy_plugin_path(); // Create new plugin in the registry with name `test`. - let plugin = ExternalPluginCommand::new("test", dummy_plugin_path, None, 100); + let plugin = ExternalPluginCommand::new("test", dummy_plugin_path, None, 100, None); // Create test module with name `test2`. let module = SoftwareModule { @@ -305,7 +306,7 @@ mod tests { // Create dummy plugin. let dummy_plugin_path = get_dummy_plugin_path(); - let plugin = ExternalPluginCommand::new("test", dummy_plugin_path, None, 100); + let plugin = ExternalPluginCommand::new("test", dummy_plugin_path, None, 100, None); // Create software module without an explicit type. let module = SoftwareModule { @@ -423,12 +424,7 @@ mod tests { fn get_dummy_plugin(name: &str) -> (ExternalPluginCommand, PathBuf) { let dummy_plugin_path = get_dummy_plugin_path(); - let plugin = ExternalPluginCommand { - name: name.into(), - path: dummy_plugin_path.clone(), - sudo: None, - max_packages: 100, - }; + let plugin = ExternalPluginCommand::new(name, &dummy_plugin_path, None, 100, None); (plugin, dummy_plugin_path) } diff --git a/crates/core/tedge_actors/src/servers/builders.rs b/crates/core/tedge_actors/src/servers/builders.rs index 1bb821e6075..40107ff06da 100644 --- a/crates/core/tedge_actors/src/servers/builders.rs +++ b/crates/core/tedge_actors/src/servers/builders.rs @@ -210,7 +210,7 @@ impl RuntimeRequestSink for ServerActorBuilder { } } -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub struct ServerConfig { pub capacity: usize, pub max_concurrency: usize, diff --git a/crates/core/tedge_agent/Cargo.toml b/crates/core/tedge_agent/Cargo.toml index 6d24afd2a8e..e5af810a706 100644 --- a/crates/core/tedge_agent/Cargo.toml +++ b/crates/core/tedge_agent/Cargo.toml @@ -20,6 +20,7 @@ lazy_static = { workspace = true } log = { workspace = true } path-clean = { workspace = true } plugin_sm = { workspace = true } +reqwest = { workspace = true } routerify = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/core/tedge_agent/src/agent.rs b/crates/core/tedge_agent/src/agent.rs index d5e41338027..bb9a8853d97 100644 --- a/crates/core/tedge_agent/src/agent.rs +++ b/crates/core/tedge_agent/src/agent.rs @@ -12,6 +12,7 @@ use camino::Utf8PathBuf; use flockfile::check_another_instance_is_not_running; use flockfile::Flockfile; use flockfile::FlockfileError; +use reqwest::Identity; use std::fmt::Debug; use std::sync::Arc; use tedge_actors::ConvertingActor; @@ -58,6 +59,7 @@ pub struct AgentConfig { pub mqtt_device_topic_id: EntityTopicId, pub mqtt_topic_root: Arc, pub service_type: String, + pub identity: Option, } impl AgentConfig { @@ -112,6 +114,8 @@ impl AgentConfig { // For agent specific let log_dir = tedge_config.logs.path.join("tedge").join("agent"); + let identity = tedge_config.http.client.auth.identity()?; + Ok(Self { mqtt_config, http_config, @@ -125,6 +129,7 @@ impl AgentConfig { mqtt_topic_root, mqtt_device_topic_id, service_type: tedge_config.service.ty.clone(), + identity, }) } } @@ -218,7 +223,8 @@ impl Agent { if v1 { let mut fs_watch_actor_builder = FsWatchActorBuilder::new(); - let mut downloader_actor_builder = DownloaderActor::new().builder(); + let mut downloader_actor_builder = + DownloaderActor::new(self.config.identity.clone()).builder(); let mut uploader_actor_builder = UploaderActor::new().builder(); // Instantiate config manager actor diff --git a/crates/core/tedge_agent/src/software_manager/error.rs b/crates/core/tedge_agent/src/software_manager/error.rs index 68aa85970da..fdeea773238 100644 --- a/crates/core/tedge_agent/src/software_manager/error.rs +++ b/crates/core/tedge_agent/src/software_manager/error.rs @@ -21,6 +21,9 @@ pub enum SoftwareManagerError { #[error(transparent)] FromTedgeConfig(#[from] tedge_config::TEdgeConfigError), + + #[error(transparent)] + Other(#[from] anyhow::Error), } impl From for tedge_actors::RuntimeError { diff --git a/crates/core/tedge_api/src/error.rs b/crates/core/tedge_api/src/error.rs index c8bae0f14ea..0f064254a28 100644 --- a/crates/core/tedge_api/src/error.rs +++ b/crates/core/tedge_api/src/error.rs @@ -15,7 +15,7 @@ pub enum TopicError { #[derive(thiserror::Error, Debug, Clone, Deserialize, Serialize, Eq, PartialEq)] pub enum SoftwareError { - #[error("DownloadError error: {reason:?} for {url:?}")] + #[error("DownloadError error: {reason:?} for {url:?}\n\nCaused by: {source_err}")] DownloadError { reason: String, url: String, diff --git a/crates/core/tedge_mapper/Cargo.toml b/crates/core/tedge_mapper/Cargo.toml index 43288e1b8f8..0d6380a3778 100644 --- a/crates/core/tedge_mapper/Cargo.toml +++ b/crates/core/tedge_mapper/Cargo.toml @@ -24,6 +24,7 @@ clock = { workspace = true } collectd_ext = { workspace = true } flockfile = { workspace = true } mqtt_channel = { workspace = true } +reqwest = { workspace = true } tedge_actors = { workspace = true } tedge_api = { workspace = true } tedge_config = { workspace = true } diff --git a/crates/core/tedge_mapper/src/c8y/mapper.rs b/crates/core/tedge_mapper/src/c8y/mapper.rs index cc26518508d..3ae281689dd 100644 --- a/crates/core/tedge_mapper/src/c8y/mapper.rs +++ b/crates/core/tedge_mapper/src/c8y/mapper.rs @@ -48,7 +48,8 @@ impl TEdgeComponent for CumulocityMapper { let mut timer_actor = TimerActor::builder(); let mut uploader_actor = UploaderActor::new().builder(); - let mut downloader_actor = DownloaderActor::new().builder(); + let identity = tedge_config.http.client.auth.identity()?; + let mut downloader_actor = DownloaderActor::new(identity).builder(); let c8y_mapper_config = C8yMapperConfig::from_tedge_config(cfg_dir, &tedge_config)?; let c8y_mapper_actor = C8yMapperBuilder::try_new( @@ -63,7 +64,7 @@ impl TEdgeComponent for CumulocityMapper { // Adaptor translating commands sent on te/device/main///cmd/+/+ into requests on tedge/commands/req/+/+ // and translating the responses received on tedge/commands/res/+/+ to te/device/main///cmd/+/+ - let old_to_new_agent_adaptator = OldAgentAdapter::builder(&mut mqtt_actor); + let old_to_new_agent_adapter = OldAgentAdapter::builder(&mut mqtt_actor); // MQTT client dedicated to set service down status on shutdown, using a last-will message // A separate MQTT actor/client is required as the last will message of the main MQTT actor @@ -82,7 +83,7 @@ impl TEdgeComponent for CumulocityMapper { runtime.spawn(service_monitor_actor).await?; runtime.spawn(uploader_actor).await?; runtime.spawn(downloader_actor).await?; - runtime.spawn(old_to_new_agent_adaptator).await?; + runtime.spawn(old_to_new_agent_adapter).await?; runtime.run_to_completion().await?; Ok(()) diff --git a/crates/extensions/c8y_auth_proxy/Cargo.toml b/crates/extensions/c8y_auth_proxy/Cargo.toml index 8bf7cb34fa5..0d4ecc78ddd 100644 --- a/crates/extensions/c8y_auth_proxy/Cargo.toml +++ b/crates/extensions/c8y_auth_proxy/Cargo.toml @@ -10,14 +10,19 @@ homepage = { workspace = true } repository = { workspace = true } [dependencies] +anyhow = { workspace = true } axum = { workspace = true, features = ["macros"] } +axum-server = { workspace = true } +axum_tls = { workspace = true } c8y_http_proxy = { workspace = true } +camino = { workspace = true } futures = { workspace = true } hyper = { workspace = true } -miette = { workspace = true } reqwest = { workspace = true, features = ["stream"] } +rustls = { workspace = true } tedge_actors = { workspace = true } tedge_config = { workspace = true } +tedge_config_macros = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process", "io-util"] } tracing = { workspace = true } url = { workspace = true } @@ -25,4 +30,5 @@ url = { workspace = true } [dev-dependencies] env_logger = { workspace = true } mockito = { workspace = true } +rcgen = { workspace = true } tedge_http_ext = { workspace = true, features = ["test_helpers" ] } diff --git a/crates/extensions/c8y_auth_proxy/src/actor.rs b/crates/extensions/c8y_auth_proxy/src/actor.rs index aa233469d98..cc7addcf1d0 100644 --- a/crates/extensions/c8y_auth_proxy/src/actor.rs +++ b/crates/extensions/c8y_auth_proxy/src/actor.rs @@ -1,9 +1,14 @@ use std::convert::Infallible; use std::net::IpAddr; +use anyhow::anyhow; +use anyhow::Context; use axum::async_trait; +use axum_tls::load_cert; +use axum_tls::load_pkey; use c8y_http_proxy::credentials::C8YJwtRetriever; use c8y_http_proxy::credentials::JwtRetriever; +use camino::Utf8PathBuf; use futures::channel::mpsc; use futures::StreamExt; use tedge_actors::Actor; @@ -14,7 +19,8 @@ use tedge_actors::RuntimeRequest; use tedge_actors::RuntimeRequestSink; use tedge_actors::Sequential; use tedge_actors::ServerActorBuilder; -use tedge_config::ConfigNotSet; +use tedge_config::ReadableKey::C8yProxyCertPath; +use tedge_config::ReadableKey::C8yProxyKeyPath; use tedge_config::TEdgeConfig; use tracing::info; @@ -23,6 +29,7 @@ use crate::server::Server; use crate::tokens::TokenManager; type BoxError = Box; +type CertKeyPair = (Vec>, Vec); pub struct C8yAuthProxyBuilder { app_state: AppState, @@ -30,19 +37,23 @@ pub struct C8yAuthProxyBuilder { bind_port: u16, signal_sender: mpsc::Sender, signal_receiver: mpsc::Receiver, + cert_and_private_key: Option, + ca_path: Option, } impl C8yAuthProxyBuilder { pub fn try_from_config( config: &TEdgeConfig, jwt: &mut ServerActorBuilder, - ) -> Result { + ) -> anyhow::Result { let app_state = AppState { target_host: format!("https://{}", config.c8y.http.or_config_not_set()?).into(), token_manager: TokenManager::new(JwtRetriever::new("C8Y-PROXY => JWT", jwt)).shared(), }; let bind = &config.c8y.proxy.bind; let (signal_sender, signal_receiver) = mpsc::channel(10); + let cert_and_private_key = load_certificate_and_key(config)?; + let ca_path = config.c8y.proxy.ca_path.or_none().cloned(); Ok(Self { app_state, @@ -50,10 +61,32 @@ impl C8yAuthProxyBuilder { bind_port: bind.port, signal_sender, signal_receiver, + cert_and_private_key, + ca_path, }) } } +fn load_certificate_and_key(config: &TEdgeConfig) -> anyhow::Result> { + let paths = tedge_config_macros::all_or_nothing(( + config.c8y.proxy.cert_path.as_ref(), + config.c8y.proxy.key_path.as_ref(), + )) + .map_err(|e| anyhow!("{e}"))?; + + if let Some((external_cert_file, external_key_file)) = paths { + Ok(Some(( + load_cert(external_cert_file) + .with_context(|| format!("reading certificate configured in {C8yProxyCertPath}"))?, + load_pkey(external_key_file).with_context(|| { + format!("reading private key configured in `{C8yProxyKeyPath}`") + })?, + ))) + } else { + Ok(None) + } +} + impl Builder for C8yAuthProxyBuilder { type Error = Infallible; @@ -63,6 +96,8 @@ impl Builder for C8yAuthProxyBuilder { bind_address: self.bind_address, bind_port: self.bind_port, signal_receiver: self.signal_receiver, + cert_and_private_key: self.cert_and_private_key, + ca_path: self.ca_path, }) } } @@ -78,6 +113,8 @@ pub struct C8yAuthProxy { bind_address: IpAddr, bind_port: u16, signal_receiver: mpsc::Receiver, + cert_and_private_key: Option<(Vec>, Vec)>, + ca_path: Option, } #[async_trait] @@ -87,9 +124,15 @@ impl Actor for C8yAuthProxy { } async fn run(mut self) -> Result<(), RuntimeError> { - let server = Server::try_init(self.app_state.clone(), self.bind_address, self.bind_port) - .map_err(BoxError::from)? - .wait(); + let server = Server::try_init( + self.app_state, + self.bind_address, + self.bind_port, + self.cert_and_private_key, + self.ca_path.as_deref(), + ) + .map_err(BoxError::from)? + .wait(); tokio::select! { result = server => { info!("Done"); diff --git a/crates/extensions/c8y_auth_proxy/src/server.rs b/crates/extensions/c8y_auth_proxy/src/server.rs index ed5363b355c..1b57279f537 100644 --- a/crates/extensions/c8y_auth_proxy/src/server.rs +++ b/crates/extensions/c8y_auth_proxy/src/server.rs @@ -1,4 +1,5 @@ use crate::tokens::*; +use anyhow::Context; use axum::body::Body; use axum::body::BoxBody; use axum::body::Full; @@ -10,44 +11,58 @@ use axum::http::HeaderValue; use axum::response::IntoResponse; use axum::response::Response; use axum::routing::get; -use axum::routing::IntoMakeService; use axum::Router; +use axum_tls::start_tls_server; +use camino::Utf8Path; use futures::future::BoxFuture; use futures::FutureExt; -use hyper::server::conn::AddrIncoming; use hyper::HeaderMap; -use miette::Context; -use miette::IntoDiagnostic; use reqwest::Method; use reqwest::StatusCode; use std::fmt; +use std::future::Future; +use std::io; use std::net::IpAddr; +use std::net::TcpListener; use std::sync::Arc; use tracing::error; use tracing::info; -type AxumServer = hyper::Server>; - pub struct Server { - fut: BoxFuture<'static, hyper::Result<()>>, + fut: BoxFuture<'static, std::io::Result<()>>, } impl Server { - pub(crate) fn try_init(state: AppState, address: IpAddr, port: u16) -> miette::Result { - Ok(Server { - fut: try_run_server(address, port, state)?.boxed(), - }) + pub(crate) fn try_init( + state: AppState, + address: IpAddr, + port: u16, + cert_and_private_key: Option<(Vec>, Vec)>, + ca_path: Option<&Utf8Path>, + ) -> anyhow::Result { + let app = create_app(state); + let root_certs = ca_path.map(axum_tls::read_trust_store).transpose()?; + let server_config = cert_and_private_key + .map(|(cert, key)| axum_tls::ssl_config(cert, key, root_certs)) + .transpose()?; + let fut = if let Some(server_config) = server_config { + try_bind_with_tls(app, address, port, server_config)?.boxed() + } else { + try_bind_insecure(app, address, port)?.boxed() + }; + + Ok(Server { fut }) } - pub fn wait(self) -> BoxFuture<'static, hyper::Result<()>> { + pub fn wait(self) -> BoxFuture<'static, std::io::Result<()>> { self.fut } } -struct ProxyError(miette::Report); +struct ProxyError(anyhow::Error); -impl From for ProxyError { - fn from(value: miette::Report) -> Self { +impl From for ProxyError { + fn from(value: anyhow::Error) -> Self { Self(value) } } @@ -63,23 +78,41 @@ impl IntoResponse for ProxyError { } } -fn try_run_server(address: IpAddr, port: u16, state: AppState) -> miette::Result { - info!("Launching on port {port}"); +fn create_app(state: AppState) -> Router<(), hyper::Body> { let handle = get(respond_to) .post(respond_to) .put(respond_to) .patch(respond_to) .delete(respond_to) .options(respond_to); - let app = Router::new() + Router::new() .route("/c8y", handle.clone()) .route("/c8y/", handle.clone()) .route("/c8y/*path", handle) - .with_state(state); - Ok(axum::Server::try_bind(&(address, port).into()) - .into_diagnostic() - .wrap_err_with(|| format!("binding to port {port}"))? - .serve(app.into_make_service())) + .with_state(state) +} + +fn try_bind_insecure( + app: Router<(), hyper::Body>, + address: IpAddr, + port: u16, +) -> anyhow::Result>> { + info!("Launching on port {port} with HTTP"); + let listener = + TcpListener::bind((address, port)).with_context(|| format!("binding to port {port}"))?; + Ok(axum_server::from_tcp(listener).serve(app.into_make_service())) +} + +fn try_bind_with_tls( + app: Router<(), hyper::Body>, + address: IpAddr, + port: u16, + server_config: rustls::ServerConfig, +) -> anyhow::Result>> { + info!("Launching on port {port} with HTTPS"); + let listener = + TcpListener::bind((address, port)).with_context(|| format!("binding to port {port}"))?; + Ok(start_tls_server(listener, server_config, app)) } #[derive(Clone)] @@ -151,8 +184,7 @@ async fn respond_to( .bearer_auth(&token) .send() .await - .into_diagnostic() - .wrap_err_with(|| format!("making HEAD request to {destination}"))?; + .with_context(|| format!("making HEAD request to {destination}"))?; if response.status() == StatusCode::UNAUTHORIZED { token = retrieve_token.not_matching(Some(&token)).await; } @@ -170,16 +202,14 @@ async fn respond_to( }; let mut res = send_request(body, &token) .await - .into_diagnostic() - .wrap_err_with(|| format!("making proxied request to {destination}"))?; + .with_context(|| format!("making proxied request to {destination}"))?; if res.status() == StatusCode::UNAUTHORIZED { token = retrieve_token.not_matching(Some(&token)).await; if let Some(body) = body_clone { res = send_request(Body::from(body), &token) .await - .into_diagnostic() - .wrap_err_with(|| format!("making proxied request to {destination}"))?; + .with_context(|| format!("making proxied request to {destination}"))?; } } let te_header = res.headers_mut().remove("transfer-encoding"); @@ -192,10 +222,7 @@ async fn respond_to( axum::body::boxed(StreamBody::new(res.bytes_stream())) } else { axum::body::boxed(Full::new( - res.bytes() - .await - .into_diagnostic() - .wrap_err("reading proxy response bytes")?, + res.bytes().await.context("reading proxy response bytes")?, )) }; @@ -209,6 +236,9 @@ mod tests { use c8y_http_proxy::credentials::JwtRequest; use c8y_http_proxy::credentials::JwtResult; use c8y_http_proxy::credentials::JwtRetriever; + use camino::Utf8PathBuf; + use futures::future::ready; + use futures::stream::once; use std::borrow::Cow; use std::net::Ipv4Addr; use tedge_actors::Sequential; @@ -230,7 +260,41 @@ mod tests { let port = start_server(&server, vec!["test-token"]); - let res = reqwest::get(format!("http://localhost:{port}/c8y/hello")) + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap(); + let res = client + .get(format!("https://localhost:{port}/c8y/hello")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 204); + } + + #[tokio::test] + async fn uses_configured_server_certificate() { + let _ = env_logger::try_init(); + + let certificate = rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap(); + let cert_der = certificate.serialize_der().unwrap(); + + let mut server = mockito::Server::new(); + let _mock = server + .mock("GET", "/hello") + .match_header("Authorization", "Bearer test-token") + .with_status(204) + .create(); + + let port = start_server_with_certificate(&server, vec!["test-token"], certificate, None); + + let client = reqwest::Client::builder() + .add_root_certificate(reqwest::tls::Certificate::from_der(&cert_der).unwrap()) + .build() + .unwrap(); + let res = client + .get(format!("https://localhost:{port}/c8y/hello")) + .send() .await .unwrap(); assert_eq!(res.status(), 204); @@ -247,7 +311,13 @@ mod tests { let port = start_server(&server, vec!["test-token"]); - let res = reqwest::get(format!("http://localhost:{port}/c8y/not-a-known-url")) + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap(); + let res = client + .get(format!("https://localhost:{port}/c8y/not-a-known-url")) + .send() .await .unwrap(); assert_eq!(res.status(), 404); @@ -257,9 +327,20 @@ mod tests { async fn responds_with_bad_gateway_on_connection_error() { let _ = env_logger::try_init(); - let port = start_server_at_url(Arc::from("127.0.0.1:0"), vec!["test-token"]); + let port = start_proxy_to_url( + "127.0.0.1:0", + vec!["test-token"], + rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap(), + None, + ); - let res = reqwest::get(format!("http://localhost:{port}/c8y/not-a-known-url")) + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap(); + let res = client + .get(format!("https://localhost:{port}/c8y/not-a-known-url")) + .send() .await .unwrap(); assert_eq!(res.status(), 502); @@ -277,11 +358,17 @@ mod tests { let port = start_server(&server, vec!["test-token"]); - let res = reqwest::get(format!( - "http://localhost:{port}/c8y/inventory/managedObjects?pageSize=100" - )) - .await - .unwrap(); + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap(); + let res = client + .get(format!( + "https://localhost:{port}/c8y/inventory/managedObjects?pageSize=100" + )) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); } @@ -297,10 +384,13 @@ mod tests { let port = start_server(&server, vec!["test-token"]); - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap(); let res = client .get(format!( - "http://localhost:{port}/c8y/inventory/managedObjects" + "https://localhost:{port}/c8y/inventory/managedObjects" )) .basic_auth("test", Some("test")) .send() @@ -328,10 +418,13 @@ mod tests { let port = start_server(&server, vec!["old-token", "test-token"]); - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap(); let body = "A body"; let res = client - .put(format!("http://localhost:{port}/c8y/hello")) + .put(format!("https://localhost:{port}/c8y/hello")) .header("Content-Length", body.bytes().len()) .body(body) .send() @@ -361,13 +454,17 @@ mod tests { let port = start_server(&server, vec!["old-token", "test-token"]); - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap(); let body = "A body"; let res = client - .put(format!("http://localhost:{port}/c8y/hello")) - .body(reqwest::Body::wrap_stream(futures::stream::once( - futures::future::ready(Ok::<_, std::convert::Infallible>(body)), - ))) + .put(format!("https://localhost:{port}/c8y/hello")) + .body(reqwest::Body::wrap_stream(once(ready(Ok::< + _, + std::convert::Infallible, + >(body))))) .send() .await .unwrap(); @@ -396,7 +493,13 @@ mod tests { let port = start_server(&server, vec!["stale-token", "test-token"]); - let res = reqwest::get(format!("http://localhost:{port}/c8y/hello")) + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap(); + let res = client + .get(format!("https://localhost:{port}/c8y/hello")) + .send() .await .unwrap(); assert_eq!(res.status(), 200); @@ -404,28 +507,59 @@ mod tests { } fn start_server(server: &mockito::Server, tokens: Vec>>) -> u16 { - start_server_at_url(server.url().into(), tokens) + start_server_with_certificate( + server, + tokens, + rcgen::generate_simple_self_signed(["localhost".to_owned()]).unwrap(), + None, + ) } - fn start_server_at_url( - target_host: Arc, + fn start_server_with_certificate( + target_host: &mockito::Server, tokens: Vec>>, + certificate: rcgen::Certificate, + ca_dir: Option, + ) -> u16 { + start_proxy_to_url(&target_host.url(), tokens, certificate, ca_dir) + } + + fn start_proxy_to_url( + target_host: &str, + tokens: Vec>>, + certificate: rcgen::Certificate, + ca_dir: Option, ) -> u16 { let mut retriever = IterJwtRetriever::builder(tokens); + let mut last_error = None; for port in 3000..3100 { let state = AppState { - target_host: target_host.clone(), + target_host: target_host.into(), token_manager: TokenManager::new(JwtRetriever::new("TEST => JWT", &mut retriever)) .shared(), }; - if let Ok(server) = try_run_server(Ipv4Addr::LOCALHOST.into(), port, state) { - tokio::spawn(server); - tokio::spawn(retriever.run()); - return port; + let trust_store = ca_dir + .as_ref() + .map(|dir| axum_tls::read_trust_store(dir).unwrap()); + let config = axum_tls::ssl_config( + vec![certificate.serialize_der().unwrap()], + certificate.serialize_private_key_der(), + trust_store, + ) + .unwrap(); + let app = create_app(state); + let res = try_bind_with_tls(app, Ipv4Addr::LOCALHOST.into(), port, config); + match res { + Ok(server) => { + tokio::spawn(server); + tokio::spawn(retriever.run()); + return port; + } + Err(e) => last_error = Some(e), } } - panic!("Failed to bind to any port"); + panic!("Failed to bind to any port: {}", last_error.unwrap()); } /// A JwtRetriever that returns a sequence of JWT tokens diff --git a/crates/extensions/c8y_auth_proxy/src/url.rs b/crates/extensions/c8y_auth_proxy/src/url.rs index 3ffee03b660..e32f982a502 100644 --- a/crates/extensions/c8y_auth_proxy/src/url.rs +++ b/crates/extensions/c8y_auth_proxy/src/url.rs @@ -5,23 +5,49 @@ use tedge_config::TEdgeConfig; pub struct ProxyUrlGenerator { host: Arc, port: u16, + protocol: Protocol, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum Protocol { + Http, + Https, +} + +impl Protocol { + pub fn as_str(self) -> &'static str { + match self { + Self::Http => "http", + Self::Https => "https", + } + } } impl ProxyUrlGenerator { - pub fn new(host: Arc, port: u16) -> Self { - Self { host, port } + pub fn new(host: Arc, port: u16, protocol: Protocol) -> Self { + Self { + host, + port, + protocol, + } } pub fn from_tedge_config(tedge_config: &TEdgeConfig) -> Self { Self { host: tedge_config.c8y.proxy.client.host.clone(), port: tedge_config.c8y.proxy.client.port, + protocol: tedge_config + .c8y + .proxy + .cert_path + .or_none() + .map_or(Protocol::Http, |_| Protocol::Https), } } pub fn proxy_url(&self, mut cumulocity_url: url::Url) -> url::Url { cumulocity_url.set_host(Some(&self.host)).unwrap(); - cumulocity_url.set_scheme("http").unwrap(); + cumulocity_url.set_scheme(self.protocol.as_str()).unwrap(); cumulocity_url.set_port(Some(self.port)).unwrap(); cumulocity_url.set_path(&format!("/c8y{}", cumulocity_url.path())); cumulocity_url @@ -33,10 +59,11 @@ mod tests { use super::*; #[test] - fn server_generates_proxy_urls_for_the_provided_port() { + fn server_generates_http_urls_for_the_provided_port() { let url_gen = ProxyUrlGenerator { host: "127.0.0.1".into(), port: 8001, + protocol: Protocol::Http, }; let url = url::Url::parse( @@ -49,4 +76,23 @@ mod tests { "http://127.0.0.1:8001/c8y/inventory/managedObjects" ) } + + #[test] + fn server_generates_https_urls_for_the_provided_port() { + let url_gen = ProxyUrlGenerator { + host: "127.0.0.1".into(), + port: 1234, + protocol: Protocol::Https, + }; + + let url = url::Url::parse( + "https://thin-edge-io.eu-latest.cumulocity.com/inventory/managedObjects", + ) + .unwrap(); + + assert_eq!( + url_gen.proxy_url(url).to_string(), + "https://127.0.0.1:1234/c8y/inventory/managedObjects" + ) + } } diff --git a/crates/extensions/c8y_http_proxy/Cargo.toml b/crates/extensions/c8y_http_proxy/Cargo.toml index d93bc835f11..ab6763935bc 100644 --- a/crates/extensions/c8y_http_proxy/Cargo.toml +++ b/crates/extensions/c8y_http_proxy/Cargo.toml @@ -10,6 +10,7 @@ homepage = { workspace = true } repository = { workspace = true } [dependencies] +anyhow = { workspace = true } async-trait = { workspace = true } c8y_api = { workspace = true } download = { workspace = true } @@ -17,6 +18,7 @@ http = { workspace = true } hyper = { workspace = true } log = { workspace = true } mqtt_channel = { workspace = true } +reqwest = { workspace = true } serde_json = { workspace = true } tedge_actors = { workspace = true } tedge_config = { workspace = true } diff --git a/crates/extensions/c8y_http_proxy/src/actor.rs b/crates/extensions/c8y_http_proxy/src/actor.rs index 4f869250f7e..0bf177b12bd 100644 --- a/crates/extensions/c8y_http_proxy/src/actor.rs +++ b/crates/extensions/c8y_http_proxy/src/actor.rs @@ -29,6 +29,7 @@ use http::status::StatusCode; use log::debug; use log::error; use log::info; +use reqwest::Identity; use std::collections::HashMap; use std::future::ready; use std::future::Future; @@ -51,6 +52,7 @@ const RETRY_TIMEOUT_SECS: u64 = 20; pub struct C8YHttpProxyActor { pub(crate) end_point: C8yEndPoint, peers: C8YHttpProxyMessageBox, + identity: Option, } pub struct C8YHttpProxyMessageBox { @@ -132,6 +134,7 @@ impl C8YHttpProxyActor { C8YHttpProxyActor { end_point, peers: message_box, + identity: config.identity, } } @@ -466,8 +469,11 @@ impl C8YHttpProxyActor { } info!(target: self.name(), "Downloading from: {:?}", download_info.url()); - let downloader: Downloader = - Downloader::with_permission(request.file_path, request.file_permissions); + let downloader: Downloader = Downloader::with_permission( + request.file_path, + request.file_permissions, + self.identity.clone(), + ); downloader.download(&download_info).await?; Ok(()) diff --git a/crates/extensions/c8y_http_proxy/src/lib.rs b/crates/extensions/c8y_http_proxy/src/lib.rs index 8e36668543b..02e948e487e 100644 --- a/crates/extensions/c8y_http_proxy/src/lib.rs +++ b/crates/extensions/c8y_http_proxy/src/lib.rs @@ -4,6 +4,7 @@ use crate::credentials::JwtResult; use crate::credentials::JwtRetriever; use crate::messages::C8YRestRequest; use crate::messages::C8YRestResult; +use reqwest::Identity; use std::convert::Infallible; use std::path::PathBuf; use tedge_actors::Builder; @@ -34,6 +35,7 @@ pub struct C8YHttpConfig { pub c8y_host: String, pub device_id: String, pub tmp_dir: PathBuf, + identity: Option, } impl TryFrom<&NewTEdgeConfig> for C8YHttpConfig { @@ -43,11 +45,13 @@ impl TryFrom<&NewTEdgeConfig> for C8YHttpConfig { let c8y_host = tedge_config.c8y.http.or_config_not_set()?.to_string(); let device_id = tedge_config.device.id.try_read(tedge_config)?.to_string(); let tmp_dir = tedge_config.tmp.path.as_std_path().to_path_buf(); + let identity = tedge_config.http.client.auth.identity()?; Ok(Self { c8y_host, device_id, tmp_dir, + identity, }) } } @@ -60,6 +64,9 @@ pub enum C8yHttpConfigBuildError { #[error(transparent)] FromConfigNotSet(#[from] ConfigNotSet), + + #[error(transparent)] + Other(#[from] anyhow::Error), } /// A proxy to C8Y REST API diff --git a/crates/extensions/c8y_http_proxy/src/tests.rs b/crates/extensions/c8y_http_proxy/src/tests.rs index 977c3957b90..cfbb62c5ac1 100644 --- a/crates/extensions/c8y_http_proxy/src/tests.rs +++ b/crates/extensions/c8y_http_proxy/src/tests.rs @@ -175,6 +175,7 @@ async fn retry_internal_id_on_expired_jwt_with_mock() { c8y_host: target_url.clone(), device_id: external_id.into(), tmp_dir: tmp_dir.into(), + identity: None, }; let c8y_proxy_actor = C8YHttpProxyBuilder::new(config, &mut http_actor, &mut jwt); let jwt_actor = ServerActor::new(DynamicJwtRetriever { count: 0 }, jwt.build()); @@ -237,6 +238,7 @@ async fn retry_create_event_on_expired_jwt_with_mock() { c8y_host: target_url.clone(), device_id: external_id.into(), tmp_dir: tmp_dir.into(), + identity: None, }; let c8y_proxy_actor = C8YHttpProxyBuilder::new(config, &mut http_actor, &mut jwt); let jwt_actor = ServerActor::new(DynamicJwtRetriever { count: 1 }, jwt.build()); @@ -461,6 +463,7 @@ async fn spawn_c8y_http_proxy( c8y_host, device_id, tmp_dir, + identity: None, }; let mut c8y_proxy_actor = C8YHttpProxyBuilder::new(config, &mut http, &mut jwt); let proxy = C8YHttpProxy::new("C8Y", &mut c8y_proxy_actor); diff --git a/crates/extensions/c8y_mapper_ext/src/actor.rs b/crates/extensions/c8y_mapper_ext/src/actor.rs index 74de84906d8..0c246d8b932 100644 --- a/crates/extensions/c8y_mapper_ext/src/actor.rs +++ b/crates/extensions/c8y_mapper_ext/src/actor.rs @@ -388,8 +388,11 @@ impl C8yMapperBuilder { let download_sender = downloader.connect_consumer(NoConfig, adapt(&box_builder.get_sender())); fs_watcher.register_peer(config.ops_dir.clone(), adapt(&box_builder.get_sender())); - let auth_proxy = - ProxyUrlGenerator::new(config.auth_proxy_addr.clone(), config.auth_proxy_port); + let auth_proxy = ProxyUrlGenerator::new( + config.auth_proxy_addr.clone(), + config.auth_proxy_port, + config.auth_proxy_protocol, + ); Ok(Self { config, diff --git a/crates/extensions/c8y_mapper_ext/src/config.rs b/crates/extensions/c8y_mapper_ext/src/config.rs index 5a6de02ca5b..63b96b209f6 100644 --- a/crates/extensions/c8y_mapper_ext/src/config.rs +++ b/crates/extensions/c8y_mapper_ext/src/config.rs @@ -2,6 +2,7 @@ use crate::Capabilities; use c8y_api::smartrest::error::OperationsError; use c8y_api::smartrest::operations::Operations; use c8y_api::smartrest::topic::C8yTopic; +use c8y_auth_proxy::url::Protocol; use camino::Utf8Path; use camino::Utf8PathBuf; use std::path::Path; @@ -40,6 +41,7 @@ pub struct C8yMapperConfig { pub capabilities: Capabilities, pub auth_proxy_addr: Arc, pub auth_proxy_port: u16, + pub auth_proxy_protocol: Protocol, pub mqtt_schema: MqttSchema, } @@ -60,6 +62,7 @@ impl C8yMapperConfig { capabilities: Capabilities, auth_proxy_addr: Arc, auth_proxy_port: u16, + auth_proxy_protocol: Protocol, mqtt_schema: MqttSchema, ) -> Self { let ops_dir = config_dir.join("operations").join("c8y"); @@ -80,6 +83,7 @@ impl C8yMapperConfig { capabilities, auth_proxy_addr, auth_proxy_port, + auth_proxy_protocol, mqtt_schema, } } @@ -104,6 +108,12 @@ impl C8yMapperConfig { let mqtt_schema = MqttSchema::with_root(tedge_config.mqtt.topic_root.clone()); let auth_proxy_addr = tedge_config.c8y.proxy.client.host.clone(); let auth_proxy_port = tedge_config.c8y.proxy.client.port; + let auth_proxy_protocol = tedge_config + .c8y + .proxy + .cert_path + .or_none() + .map_or(Protocol::Http, |_| Protocol::Https); let tedge_http_host = format!("{}:{}", tedge_http_address, tedge_http_port).into(); @@ -161,6 +171,7 @@ impl C8yMapperConfig { capabilities, auth_proxy_addr, auth_proxy_port, + auth_proxy_protocol, mqtt_schema, )) } diff --git a/crates/extensions/c8y_mapper_ext/src/converter.rs b/crates/extensions/c8y_mapper_ext/src/converter.rs index a4951a3b87c..c363680285e 100644 --- a/crates/extensions/c8y_mapper_ext/src/converter.rs +++ b/crates/extensions/c8y_mapper_ext/src/converter.rs @@ -1565,6 +1565,7 @@ pub(crate) mod tests { use assert_matches::assert_matches; use c8y_api::smartrest::operations::ResultFormat; use c8y_api::smartrest::topic::SMARTREST_PUBLISH_TOPIC; + use c8y_auth_proxy::url::Protocol; use c8y_auth_proxy::url::ProxyUrlGenerator; use c8y_http_proxy::handle::C8YHttpProxy; use c8y_http_proxy::messages::C8YRestRequest; @@ -3042,6 +3043,7 @@ pub(crate) mod tests { let mqtt_schema = MqttSchema::default(); let auth_proxy_addr = "127.0.0.1".into(); let auth_proxy_port = 8001; + let auth_proxy_protocol = Protocol::Http; let mut topics = C8yMapperConfig::default_internal_topic_filter(&tmp_dir.to_path_buf()).unwrap(); topics.add_all(crate::log_upload::log_upload_topic_filter(&mqtt_schema)); @@ -3062,6 +3064,7 @@ pub(crate) mod tests { Capabilities::default(), auth_proxy_addr, auth_proxy_port, + auth_proxy_protocol, MqttSchema::default(), ) } @@ -3081,7 +3084,7 @@ pub(crate) mod tests { let auth_proxy_addr = config.auth_proxy_addr.clone(); let auth_proxy_port = config.auth_proxy_port; - let auth_proxy = ProxyUrlGenerator::new(auth_proxy_addr, auth_proxy_port); + let auth_proxy = ProxyUrlGenerator::new(auth_proxy_addr, auth_proxy_port, Protocol::Http); let uploader_builder: SimpleMessageBoxBuilder = SimpleMessageBoxBuilder::new("UL", 5); diff --git a/crates/extensions/c8y_mapper_ext/src/tests.rs b/crates/extensions/c8y_mapper_ext/src/tests.rs index 3c16f1330c0..b3352bc6602 100644 --- a/crates/extensions/c8y_mapper_ext/src/tests.rs +++ b/crates/extensions/c8y_mapper_ext/src/tests.rs @@ -9,6 +9,7 @@ use crate::actor::IdUploadResult; use crate::Capabilities; use assert_json_diff::assert_json_include; use c8y_api::smartrest::topic::C8yTopic; +use c8y_auth_proxy::url::Protocol; use c8y_http_proxy::messages::C8YRestRequest; use c8y_http_proxy::messages::C8YRestResult; use serde_json::json; @@ -2593,6 +2594,7 @@ pub(crate) async fn spawn_c8y_mapper_actor( Capabilities::default(), auth_proxy_addr, auth_proxy_port, + Protocol::Http, MqttSchema::default(), ); diff --git a/crates/extensions/tedge_downloader_ext/Cargo.toml b/crates/extensions/tedge_downloader_ext/Cargo.toml index d7b2e32cdc5..084da8f7e7a 100644 --- a/crates/extensions/tedge_downloader_ext/Cargo.toml +++ b/crates/extensions/tedge_downloader_ext/Cargo.toml @@ -13,6 +13,7 @@ repository = { workspace = true } async-trait = { workspace = true } download = { workspace = true } log = { workspace = true } +reqwest = { workspace = true } tedge_actors = { workspace = true } tedge_utils = { workspace = true } diff --git a/crates/extensions/tedge_downloader_ext/src/actor.rs b/crates/extensions/tedge_downloader_ext/src/actor.rs index 0fff364ec2d..7d0be137c1d 100644 --- a/crates/extensions/tedge_downloader_ext/src/actor.rs +++ b/crates/extensions/tedge_downloader_ext/src/actor.rs @@ -4,6 +4,8 @@ use download::DownloadError; use download::DownloadInfo; use download::Downloader; use log::info; +use reqwest::Identity; +use std::marker::PhantomData; use std::path::Path; use std::path::PathBuf; use tedge_actors::Message; @@ -63,25 +65,41 @@ impl DownloadResponse { } } -#[derive(Debug, Default)] +#[derive(Debug)] pub struct DownloaderActor { config: ServerConfig, key: std::marker::PhantomData, + identity: Option, +} + +impl Clone for DownloaderActor { + fn clone(&self) -> Self { + DownloaderActor { + config: self.config, + key: self.key, + identity: self.identity.clone(), + } + } } impl DownloaderActor { - pub fn new() -> Self { - DownloaderActor::default() + pub fn new(identity: Option) -> Self { + DownloaderActor { + config: <_>::default(), + key: PhantomData, + identity, + } } pub fn builder(&self) -> ServerActorBuilder, Sequential> { - ServerActorBuilder::new(DownloaderActor::default(), &ServerConfig::new(), Sequential) + ServerActorBuilder::new(self.clone(), &ServerConfig::new(), Sequential) } - pub fn with_capacity(self, capacity: usize) -> Self { + pub fn with_capacity(self, capacity: usize, identity: Option) -> Self { Self { config: self.config.with_capacity(capacity), key: self.key, + identity, } } } @@ -105,9 +123,13 @@ impl Server for DownloaderActor { }; let downloader = if let Some(permission) = request.permission { - Downloader::with_permission(request.file_path.clone(), permission) + Downloader::with_permission( + request.file_path.clone(), + permission, + self.identity.clone(), + ) } else { - Downloader::new(request.file_path.clone()) + Downloader::new(request.file_path.clone(), self.identity.clone()) }; info!( diff --git a/crates/extensions/tedge_downloader_ext/src/tests.rs b/crates/extensions/tedge_downloader_ext/src/tests.rs index 41fd1f48a3d..b06537ca339 100644 --- a/crates/extensions/tedge_downloader_ext/src/tests.rs +++ b/crates/extensions/tedge_downloader_ext/src/tests.rs @@ -109,7 +109,7 @@ async fn download_with_permission() { async fn spawn_downloader_actor( ) -> ClientMessageBox<(String, DownloadRequest), (String, DownloadResult)> { - let mut downloader_actor_builder = DownloaderActor::new().builder(); + let mut downloader_actor_builder = DownloaderActor::new(None).builder(); let requester = ClientMessageBox::new("DownloadRequester", &mut downloader_actor_builder); tokio::spawn(downloader_actor_builder.run()); @@ -159,7 +159,7 @@ struct TestDownloadKey { async fn spawn_downloader_actor_with_struct( ) -> ClientMessageBox<(TestDownloadKey, DownloadRequest), (TestDownloadKey, DownloadResult)> { - let mut downloader_actor_builder = DownloaderActor::new().builder(); + let mut downloader_actor_builder = DownloaderActor::new(None).builder(); let requester = ClientMessageBox::new("DownloadRequester2", &mut downloader_actor_builder); tokio::spawn(downloader_actor_builder.run()); diff --git a/docs/src/references/tedge-cumulocity-proxy.md b/docs/src/references/tedge-cumulocity-proxy.md index 296855e85f5..670107c6f1a 100644 --- a/docs/src/references/tedge-cumulocity-proxy.md +++ b/docs/src/references/tedge-cumulocity-proxy.md @@ -25,8 +25,21 @@ The server supports all public REST APIs of Cumulocity, and all possible request There is no need to provide an `Authorization` header (or any other authentication method) when accessing the API. If an `Authorization` header is provided, this will be used to authenticate the request instead of the device JWT. -At the time of writing, this service is unauthenticated and does not support incoming HTTPS connections +## HTTPS and authenticated access +By default, the service is unauthenticated and does not support incoming HTTPS connections (when the request is forwarded to Cumulocity, however, this will use HTTPS). +HTTPS can be enabled by setting `c8y.proxy.cert_path` and `c8y.proxy.key_path`. +If the certificates are configured, the mapper will automatically host the proxy via HTTPS, and redirect any +HTTP requests to the equivalent HTTPS URL. +If HTTPS is enabled, the configured certificate should be installed in the OS trust store for any connected agents +in order for them to trust the connection to the mapper. + +Once HTTPS is enabled for the mapper, certificate-based authentication can also be enabled. +The directory containing the certificates that the mapper will trust can be configured using `c8y.proxy.ca_path`, +and the agent can be configured to use a trusted certificate using the `http.client.auth.cert_file` and `http.client.auth.key_file` +setings. + +## Possible errors returned by the proxy Due to the underlying JWT handling in Cumulocity, requests to the proxy API are occasionally spuriously rejected with a `401 Not Authorized` status code. The proxy server currently forwards this response directly to the client, as well as all other errors responses from diff --git a/plugins/c8y_firmware_plugin/src/lib.rs b/plugins/c8y_firmware_plugin/src/lib.rs index d3361e07f16..f361ffab9fc 100644 --- a/plugins/c8y_firmware_plugin/src/lib.rs +++ b/plugins/c8y_firmware_plugin/src/lib.rs @@ -85,7 +85,8 @@ async fn run_with(tedge_config: TEdgeConfig) -> Result<(), anyhow::Error> { let mqtt_config = tedge_config.mqtt_config()?; let mut jwt_actor = C8YJwtRetriever::builder(mqtt_config.clone()); let mut timer_actor = TimerActor::builder(); - let mut downloader_actor = DownloaderActor::new().builder(); + let identity = tedge_config.http.client.auth.identity()?; + let mut downloader_actor = DownloaderActor::new(identity).builder(); let mut mqtt_actor = MqttActorBuilder::new(mqtt_config.clone().with_session_name(PLUGIN_NAME)); //Instantiate health monitor actor diff --git a/plugins/tedge_configuration_plugin/src/lib.rs b/plugins/tedge_configuration_plugin/src/lib.rs index 2cc2020bda0..6ff64537101 100644 --- a/plugins/tedge_configuration_plugin/src/lib.rs +++ b/plugins/tedge_configuration_plugin/src/lib.rs @@ -123,8 +123,9 @@ async fn run_with( &mqtt_schema, tedge_config.service.ty.clone(), ); + let identity = tedge_config.http.client.auth.identity()?; - let mut downloader_actor = DownloaderActor::new().builder(); + let mut downloader_actor = DownloaderActor::new(identity).builder(); let mut uploader_actor = UploaderActor::new().builder();