diff --git a/Cargo.lock b/Cargo.lock index 94bf7a7961..a913bbecc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,45 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -179,6 +218,7 @@ dependencies = [ "authentik-client", "authentik-common", "axum", + "axum-server", "color-eyre", "eyre", "futures", @@ -190,6 +230,7 @@ dependencies = [ "pyo3", "pyo3-build-config", "rand 0.10.1", + "rustls", "serde", "serde_json", "serde_repr", @@ -258,6 +299,7 @@ dependencies = [ "nix 0.31.3", "notify", "pin-project-lite", + "rcgen", "reqwest", "reqwest-middleware", "rustls", @@ -923,6 +965,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2216,12 +2272,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2408,6 +2483,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2824,6 +2908,19 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "aws-lc-rs", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2958,6 +3055,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4921,6 +5027,24 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "yaml-rust2" version = "0.10.4" @@ -4932,6 +5056,15 @@ dependencies = [ "hashlink 0.10.0", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index f065f05c3b..c22f0589ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,10 @@ pin-project-lite = "= 0.2.17" pyo3 = "= 0.29.0" pyo3-build-config = "= 0.29.0" rand = "= 0.10.1" +rcgen = { version = "= 0.14.7", default-features = false, features = [ + "aws_lc_rs", + "fips", +] } regex = "= 1.12.4" reqwest = { version = "= 0.13.4", features = [ "form", @@ -276,6 +280,7 @@ ak-client = { workspace = true, optional = true } ak-common.workspace = true arc-swap.workspace = true argh.workspace = true +axum-server.workspace = true axum.workspace = true color-eyre.workspace = true eyre.workspace = true @@ -287,6 +292,7 @@ metrics.workspace = true nix.workspace = true pyo3 = { workspace = true, optional = true } rand.workspace = true +rustls.workspace = true serde.workspace = true serde_json.workspace = true serde_repr.workspace = true diff --git a/packages/ak-common/Cargo.toml b/packages/ak-common/Cargo.toml index 771356ae8d..6d3361295e 100644 --- a/packages/ak-common/Cargo.toml +++ b/packages/ak-common/Cargo.toml @@ -27,6 +27,7 @@ ipnet.workspace = true json-subscriber.workspace = true notify.workspace = true pin-project-lite.workspace = true +rcgen.workspace = true reqwest.workspace = true reqwest-middleware.workspace = true rustls.workspace = true diff --git a/packages/ak-common/src/config/schema.rs b/packages/ak-common/src/config/schema.rs index e892ebd369..3c8a2cb3fb 100644 --- a/packages/ak-common/src/config/schema.rs +++ b/packages/ak-common/src/config/schema.rs @@ -3,8 +3,9 @@ use std::{collections::HashMap, net::SocketAddr, num::NonZeroUsize}; use ipnet::IpNet; use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; -pub(super) const KEYS_TO_PARSE_AS_LIST: [&str; 4] = [ +pub(super) const KEYS_TO_PARSE_AS_LIST: [&str; 5] = [ "listen.http", + "listen.https", "listen.metrics", "listen.trusted_proxy_cidrs", "log.http_headers", @@ -86,6 +87,7 @@ pub struct PostgreSQLConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListenConfig { pub http: Vec, + pub https: Vec, pub metrics: Vec, pub debug_tokio: SocketAddr, pub trusted_proxy_cidrs: Vec, diff --git a/packages/ak-common/src/tls.rs b/packages/ak-common/src/tls/mod.rs similarity index 98% rename from packages/ak-common/src/tls.rs rename to packages/ak-common/src/tls/mod.rs index f3e52ce4b7..ed619d524c 100644 --- a/packages/ak-common/src/tls.rs +++ b/packages/ak-common/src/tls/mod.rs @@ -7,6 +7,8 @@ use tracing::trace; use crate::config; +pub mod self_signed; + /// Dummy resolver for FIPS compliance check. #[derive(Debug)] struct EmptyCertResolver; diff --git a/packages/ak-common/src/tls/self_signed.rs b/packages/ak-common/src/tls/self_signed.rs new file mode 100644 index 0000000000..3cb1f2ed9f --- /dev/null +++ b/packages/ak-common/src/tls/self_signed.rs @@ -0,0 +1,52 @@ +use eyre::Result; +use rcgen::{ + Certificate, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, KeyPair, + KeyUsagePurpose, PKCS_RSA_SHA256, SanType, +}; +use rustls::{ + crypto::aws_lc_rs::sign::any_supported_type, + pki_types::{CertificateDer, PrivateKeyDer}, + sign::CertifiedKey, +}; +use time::{Duration, OffsetDateTime}; + +pub fn generate() -> Result<(Certificate, KeyPair)> { + let signing_key = KeyPair::generate_for(&PKCS_RSA_SHA256)?; + + let mut params = CertificateParams::default(); + params.not_before = OffsetDateTime::now_utc(); + params.not_after = OffsetDateTime::now_utc() + Duration::days(365); + params.distinguished_name = { + let mut dn = DistinguishedName::new(); + dn.push(DnType::OrganizationName, "authentik"); + dn.push(DnType::CommonName, "authentik default certificate"); + dn + }; + params.subject_alt_names = vec![SanType::DnsName("*".try_into()?)]; + params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + + let cert = params.self_signed(&signing_key)?; + + Ok((cert, signing_key)) +} + +pub fn generate_certifiedkey() -> Result { + let (cert, keypair) = generate()?; + + let cert_der = cert.der().to_vec(); + let key_der = keypair.serialize_der(); + + let private_key = + PrivateKeyDer::try_from(key_der).map_err(|_| rcgen::Error::CouldNotParseKeyPair)?; + let signing_key = + any_supported_type(&private_key).map_err(|_| rcgen::Error::CouldNotParseKeyPair)?; + + Ok(CertifiedKey::new( + vec![CertificateDer::from(cert_der)], + signing_key, + )) +} diff --git a/src/outpost/proxy/application.rs b/src/outpost/proxy/application.rs index c0b62963f9..ffb7a40a88 100644 --- a/src/outpost/proxy/application.rs +++ b/src/outpost/proxy/application.rs @@ -1,6 +1,19 @@ -use ak_client::models::ProxyOutpostConfig; +use std::sync::Arc; + +use ak_client::{ + apis::crypto_api::{ + crypto_certificatekeypairs_view_certificate_retrieve, + crypto_certificatekeypairs_view_private_key_retrieve, + }, + models::ProxyOutpostConfig, +}; use axum::Router; use eyre::{Result, eyre}; +use rustls::{ + crypto::CryptoProvider, + pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _}, + sign::CertifiedKey, +}; use tracing::instrument; use url::Url; @@ -15,17 +28,44 @@ pub(super) struct Application { pub(super) host: String, pub(super) provider: ProxyOutpostConfig, pub(super) router: Router, + pub(super) cert: Option>, } impl Application { #[instrument(skip_all)] - pub(super) fn new(_existing_apps: &ProxyOutpost, provider: ProxyOutpostConfig) -> Result { + pub(super) async fn new(outpost: &ProxyOutpost, provider: ProxyOutpostConfig) -> Result { let external_url = Url::parse(&provider.external_host)?; if !external_url.has_authority() { return Err(eyre!("no host in external host")); } let external_host = external_url.authority(); + // TODO: extract this to a certificate store to avoid re-fetching the certificate every time + let cert = if let Some(Some(kp_uuid)) = provider.certificate { + let cert = crypto_certificatekeypairs_view_certificate_retrieve( + &outpost.controller.api_config, + &kp_uuid.to_string(), + None, + ) + .await?; + let key = crypto_certificatekeypairs_view_private_key_retrieve( + &outpost.controller.api_config, + &kp_uuid.to_string(), + None, + ) + .await?; + let cert_chain = CertificateDer::pem_reader_iter(cert.data.as_bytes()) + .collect::, _>>()?; + let key_der = PrivateKeyDer::from_pem_reader(key.data.as_bytes())?; + let provider = CryptoProvider::get_default().expect("no rustls provider installed"); + Some(Arc::new(CertifiedKey::new( + cert_chain, + provider.key_provider.load_private_key(key_der)?, + ))) + } else { + None + }; + let _redirect_url = { let mut redirect_url = external_url.join("outpost.goauthentik.io/callback")?; redirect_url.set_query(Some(&format!("{CALLBACK_SIGNATURE}=true"))); @@ -36,6 +76,7 @@ impl Application { host: external_host.to_owned(), provider, router: Router::new(), + cert, }) } } diff --git a/src/outpost/proxy/handlers.rs b/src/outpost/proxy/handlers.rs index 8c88c061e9..16b9b5f736 100644 --- a/src/outpost/proxy/handlers.rs +++ b/src/outpost/proxy/handlers.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use tower::util::ServiceExt as _; use ak_axum::{error::Result, extract::host::Host}; use axum::{ @@ -10,6 +9,7 @@ use axum::{ use metrics::histogram; use serde_json::json; use tokio::time::Instant; +use tower::util::ServiceExt as _; use tracing::{Instrument as _, field, info_span, instrument, trace, warn}; use crate::outpost::proxy::ProxyOutpost; diff --git a/src/outpost/proxy/mod.rs b/src/outpost/proxy/mod.rs index 8e33976721..9e5ec6f243 100644 --- a/src/outpost/proxy/mod.rs +++ b/src/outpost/proxy/mod.rs @@ -2,11 +2,17 @@ use std::{collections::HashMap, sync::Arc}; use ak_axum::router::wrap_router; use ak_client::{apis::outposts_api::outposts_proxy_list, models::ProxyMode}; -use ak_common::{Tasks, api::fetch_all, config}; +use ak_common::{Tasks, api::fetch_all, config, tls}; use arc_swap::ArcSwap; use argh::FromArgs; use axum::Router; +use axum_server::tls_rustls::RustlsConfig; use eyre::Result; +use rustls::{ + ServerConfig, + server::{ClientHello, ResolvesServerCert}, + sign::CertifiedKey, +}; use tracing::{debug, error, info, instrument, warn}; use crate::outpost::{Outpost, OutpostController, proxy::application::Application}; @@ -23,9 +29,11 @@ mod handlers; )] pub(crate) struct Cli {} +#[derive(Debug)] pub(crate) struct ProxyOutpost { controller: Arc, apps: ArcSwap>>, + default_cert: Arc, } impl Outpost for ProxyOutpost { @@ -38,14 +46,30 @@ impl Outpost for ProxyOutpost { Ok(Self { controller, apps: ArcSwap::from_pointee(HashMap::with_capacity(0)), + default_cert: Arc::new(tls::self_signed::generate_certifiedkey()?), }) } fn start(self: Arc, tasks: &mut Tasks) -> Result<()> { - let router = build_router(self); + let router = build_router(Arc::clone(&self)); for addr in config::get().listen.http.iter().copied() { - ak_axum::server::start_plain(tasks, "proxy-outpost", router.clone(), addr, false)?; + ak_axum::server::start_plain(tasks, "proxy-outpost", router.clone(), addr)?; + } + + for addr in config::get().listen.https.iter().copied() { + let resolver = Arc::clone(&self); + let server_config = ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(resolver); + let rustls_config = RustlsConfig::from_config(Arc::new(server_config)); + ak_axum::server::start_tls( + tasks, + "proxy-outpost", + router.clone(), + addr, + rustls_config, + )?; } Ok(()) @@ -97,6 +121,7 @@ impl Outpost for ProxyOutpost { for provider in providers { let name = provider.name.clone(); let Ok(application) = Application::new(self, provider) + .await .inspect_err(|err| warn!(?err, "failed to setup application, skipping provider")) else { continue; @@ -118,6 +143,22 @@ impl Outpost for ProxyOutpost { } } +impl ResolvesServerCert for ProxyOutpost { + fn resolve(&self, client_hello: ClientHello<'_>) -> Option> { + if let Some(server_name) = client_hello.server_name() + && let Some(app) = self.apps.load().get(server_name) + && let Some(cert) = &app.cert + { + return Some(Arc::clone(cert)); + } + Some(Arc::clone(&self.default_cert)) + } + + fn only_raw_public_keys(&self) -> bool { + false + } +} + impl ProxyOutpost { #[instrument(skip(self))] fn lookup_app(&self, host: &str) -> Option> {