From 8ff3e9a5cf8bd24ab46f96208c09d6079bacea82 Mon Sep 17 00:00:00 2001 From: Eugene Date: Wed, 2 Jul 2025 08:11:22 +0200 Subject: [PATCH] fixed #1104 - SNI support (#1402) --- Cargo.lock | 89 ++++++++++++++ config-schema.json | 23 ++++ warpgate-common/Cargo.toml | 1 + warpgate-common/src/config/mod.rs | 10 ++ warpgate-common/src/tls/cert.rs | 115 +++++++++++++++++- warpgate-common/src/tls/error.rs | 3 + warpgate-core/src/logging/socket.rs | 7 +- warpgate-protocol-http/src/lib.rs | 59 ++++----- warpgate-protocol-http/src/proxy.rs | 4 +- .../src/gateway/lib/openapi-schema.json | 59 +++++---- 10 files changed, 312 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 466ef880..15a7d38b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,6 +197,45 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.12", + "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 2.0.101", + "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 2.0.101", +] + [[package]] name = "async-compression" version = "0.4.22" @@ -1020,6 +1059,20 @@ dependencies = [ "zeroize", ] +[[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.4.0" @@ -2587,6 +2640,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.3" @@ -3786,6 +3848,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 = "0.38.44" @@ -5436,6 +5507,7 @@ dependencies = [ "uuid", "warpgate-sso", "webpki", + "x509-parser", ] [[package]] @@ -6232,6 +6304,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.12", + "time", +] + [[package]] name = "yaml-rust2" version = "0.10.2" diff --git a/config-schema.json b/config-schema.json index 218edad2..3fb60448 100644 --- a/config-schema.json +++ b/config-schema.json @@ -24,6 +24,7 @@ "key": "", "listen": "[::]:8888", "session_max_age": "30m", + "sni_certificates": [], "trust_x_forwarded_headers": false } }, @@ -138,6 +139,13 @@ "type": "string", "default": "30m" }, + "sni_certificates": { + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/SniCertificateConfig" + } + }, "trust_x_forwarded_headers": { "type": "boolean", "default": false @@ -238,6 +246,21 @@ } } }, + "SniCertificateConfig": { + "type": "object", + "properties": { + "certificate": { + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "certificate", + "key" + ] + }, "SshConfig": { "type": "object", "properties": { diff --git a/warpgate-common/Cargo.toml b/warpgate-common/Cargo.toml index 8eecba57..e4e63a77 100644 --- a/warpgate-common/Cargo.toml +++ b/warpgate-common/Cargo.toml @@ -45,3 +45,4 @@ webpki = { version = "0.22", default-features = false } tokio-stream.workspace = true git-version = { version = "0.3.9", default-features = false } schemars.workspace = true +x509-parser = "0.17.0" diff --git a/warpgate-common/src/config/mod.rs b/warpgate-common/src/config/mod.rs index 933fbb49..0fac89bf 100644 --- a/warpgate-common/src/config/mod.rs +++ b/warpgate-common/src/config/mod.rs @@ -217,6 +217,12 @@ impl SshConfig { } } +#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] +pub struct SniCertificateConfig { + pub certificate: String, + pub key: String, +} + #[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct HttpConfig { #[serde(default = "_default_false")] @@ -244,6 +250,9 @@ pub struct HttpConfig { #[serde(default = "_default_cookie_max_age", with = "humantime_serde")] #[schemars(with = "String")] pub cookie_max_age: Duration, + + #[serde(default)] + pub sni_certificates: Vec, } impl Default for HttpConfig { @@ -257,6 +266,7 @@ impl Default for HttpConfig { trust_x_forwarded_headers: false, session_max_age: _default_session_max_age(), cookie_max_age: _default_cookie_max_age(), + sni_certificates: vec![], } } } diff --git a/warpgate-common/src/tls/cert.rs b/warpgate-common/src/tls/cert.rs index b260ccbc..5c7517a2 100644 --- a/warpgate-common/src/tls/cert.rs +++ b/warpgate-common/src/tls/cert.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use poem::listener::RustlsCertificate; @@ -6,19 +6,23 @@ use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use rustls::sign::{CertifiedKey, SigningKey}; use tokio::fs::File; use tokio::io::AsyncReadExt; +use x509_parser::prelude::{FromDer, GeneralName, ParsedExtension, X509Certificate}; -use crate::RustlsSetupError; +use crate::{HttpConfig, RustlsSetupError, SniCertificateConfig, WarpgateConfig}; +#[derive(Clone)] pub struct TlsCertificateBundle { bytes: Vec, certificates: Vec>, } +#[derive(Clone)] pub struct TlsPrivateKey { bytes: Vec, key: Arc, } +#[derive(Clone)] pub struct TlsCertificateAndPrivateKey { pub certificate: TlsCertificateBundle, pub private_key: TlsPrivateKey, @@ -44,6 +48,74 @@ impl TlsCertificateBundle { certificates, }) } + + pub fn sni_names(&self) -> Result, RustlsSetupError> { + if self.certificates.is_empty() { + return Ok(Vec::new()); + } + + // Parse leaf certificate + let cert_der = &self.certificates[0]; + let (_, cert) = + X509Certificate::from_der(cert_der).map_err(|e| RustlsSetupError::X509(e.into()))?; + + let mut names = Vec::new(); + + if let Some(san_ext) = cert + .extensions() + .iter() + .find(|ext| ext.oid == x509_parser::oid_registry::OID_X509_EXT_SUBJECT_ALT_NAME) + { + let san = san_ext.parsed_extension(); + if let ParsedExtension::SubjectAlternativeName(san) = san { + for name in &san.general_names { + match name { + GeneralName::DNSName(dns_name) => { + names.push(dns_name.to_string()); + } + GeneralName::IPAddress(ip_bytes) => { + // Convert IP bytes to string representation + if ip_bytes.len() == 4 { + // IPv4 + names.push(format!( + "{}.{}.{}.{}", + ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3] + )); + } else if ip_bytes.len() == 16 { + // IPv6 + let mut ipv6_parts = Vec::new(); + for chunk in ip_bytes.chunks(2) { + ipv6_parts.push(format!( + "{:02x}{:02x}", + chunk[0], + chunk.get(1).unwrap_or(&0) + )); + } + names.push(ipv6_parts.join(":")); + } + } + _ => {} // Ignore other types like email, URI, etc. + } + } + } + } + + if let Some(subject) = cert.subject().iter_common_name().next() { + if let Ok(cn) = subject.as_str() { + names.push(cn.to_string()); + } + } + + // Remove duplicates while preserving order + let mut unique_names = Vec::new(); + for name in names { + if !unique_names.contains(&name) { + unique_names.push(name); + } + } + + Ok(unique_names) + } } impl TlsPrivateKey { @@ -110,3 +182,42 @@ impl From for CertifiedKey { } } } + +pub trait IntoTlsCertificateRelativePaths { + fn certificate_path(&self) -> PathBuf; + fn key_path(&self) -> PathBuf; +} + +impl IntoTlsCertificateRelativePaths for HttpConfig { + fn certificate_path(&self) -> PathBuf { + self.certificate.as_str().into() + } + + fn key_path(&self) -> PathBuf { + self.key.as_str().into() + } +} + +impl IntoTlsCertificateRelativePaths for SniCertificateConfig { + fn certificate_path(&self) -> PathBuf { + self.certificate.as_str().into() + } + + fn key_path(&self) -> PathBuf { + self.key.as_str().into() + } +} + +pub async fn load_certificate_and_key( + from: &R, + config: &WarpgateConfig, +) -> Result { + Ok(TlsCertificateAndPrivateKey { + certificate: TlsCertificateBundle::from_file( + config.paths_relative_to.join(&from.certificate_path()), + ) + .await?, + private_key: TlsPrivateKey::from_file(config.paths_relative_to.join(&from.key_path())) + .await?, + }) +} diff --git a/warpgate-common/src/tls/error.rs b/warpgate-common/src/tls/error.rs index cf6e1e95..c52ce3b5 100644 --- a/warpgate-common/src/tls/error.rs +++ b/warpgate-common/src/tls/error.rs @@ -1,4 +1,5 @@ use rustls::server::VerifierBuilderError; +use x509_parser::error::X509Error; #[derive(thiserror::Error, Debug)] pub enum RustlsSetupError { @@ -14,4 +15,6 @@ pub enum RustlsSetupError { Io(#[from] std::io::Error), #[error("PKI: {0}")] Pki(webpki::Error), + #[error("parsing certificate: {0}")] + X509(#[from] X509Error), } diff --git a/warpgate-core/src/logging/socket.rs b/warpgate-core/src/logging/socket.rs index de1a190c..bac1c944 100644 --- a/warpgate-core/src/logging/socket.rs +++ b/warpgate-core/src/logging/socket.rs @@ -1,6 +1,6 @@ use bytes::BytesMut; +use chrono::format::SecondsFormat; use chrono::Local; -use chrono::format::{SecondsFormat}; use tokio::net::UnixDatagram; use tracing::*; use tracing_subscriber::registry::LookupSpan; @@ -33,7 +33,10 @@ where if !got_socket || values.contains_key(&SKIP_KEY) { return; } - values.insert("timestamp", Local::now().to_rfc3339_opts(SecondsFormat::Nanos, false)); + values.insert( + "timestamp", + Local::now().to_rfc3339_opts(SecondsFormat::Nanos, false), + ); let _ = tx.try_send(values); }); diff --git a/warpgate-protocol-http/src/lib.rs b/warpgate-protocol-http/src/lib.rs index d5e25a4f..b85e8b7f 100644 --- a/warpgate-protocol-http/src/lib.rs +++ b/warpgate-protocol-http/src/lib.rs @@ -29,8 +29,7 @@ use tracing::*; use warpgate_admin::admin_api_app; use warpgate_common::version::warpgate_version; use warpgate_common::{ - ListenEndpoint, Target, TargetOptions, TlsCertificateAndPrivateKey, TlsCertificateBundle, - TlsPrivateKey, + load_certificate_and_key, ListenEndpoint, Target, TargetOptions, WarpgateConfig, }; use warpgate_core::{ProtocolServer, Services, TargetTestError}; use warpgate_web::Assets; @@ -56,6 +55,30 @@ fn make_session_storage() -> SharedSessionStorage { SharedSessionStorage(Arc::new(Mutex::new(Box::::default()))) } +async fn make_rustls_config(config: &WarpgateConfig) -> Result { + let certificate_and_key = load_certificate_and_key(&config.store.http, config) + .await + .with_context(|| { + format!( + "loading TLS certificate and key: {}", + config.store.http.certificate, + ) + })?; + + let mut cfg = RustlsConfig::new().fallback(certificate_and_key.into()); + for sni in &config.store.http.sni_certificates { + let certificate_and_key = load_certificate_and_key(sni, &config) + .await + .with_context(|| format!("loading SNI TLS certificate: {sni:?}",))?; + + for name in certificate_and_key.certificate.sni_names()? { + debug!(?name, source=?sni, "Adding SNI certificate"); + cfg = cfg.certificate(name, certificate_and_key.clone().into()); + } + } + Ok(cfg) +} + impl ProtocolServer for HTTPProtocolServer { async fn run(self, address: ListenEndpoint) -> Result<()> { let admin_api_app = admin_api_app(&self.services).into_endpoint(); @@ -181,37 +204,15 @@ impl ProtocolServer for HTTPProtocolServer { } }); - let certificate_and_key = { + let rustls_config = { let config = self.services.config.lock().await; - let certificate_path = config - .paths_relative_to - .join(&config.store.http.certificate); - let key_path = config.paths_relative_to.join(&config.store.http.key); - - TlsCertificateAndPrivateKey { - certificate: TlsCertificateBundle::from_file(&certificate_path) - .await - .with_context(|| { - format!("reading TLS private key from '{}'", key_path.display()) - })?, - private_key: TlsPrivateKey::from_file(&key_path).await.with_context(|| { - format!( - "reading TLS certificate from '{}'", - certificate_path.display() - ) - })?, - } + make_rustls_config(&config).await? }; info!(?address, "Listening"); - Server::new( - address - .poem_listener() - .await? - .rustls(RustlsConfig::new().fallback(certificate_and_key.into())), - ) - .run(app) - .await?; + Server::new(address.poem_listener().await?.rustls(rustls_config)) + .run(app) + .await?; Ok(()) } diff --git a/warpgate-protocol-http/src/proxy.rs b/warpgate-protocol-http/src/proxy.rs index 96990484..879b6218 100644 --- a/warpgate-protocol-http/src/proxy.rs +++ b/warpgate-protocol-http/src/proxy.rs @@ -17,7 +17,9 @@ use poem::{Body, FromRequest, IntoResponse, Request, Response}; use tokio_tungstenite::{connect_async_tls_with_config, tungstenite, Connector}; use tracing::*; use url::Url; -use warpgate_common::{configure_tls_connector, try_block, TargetHTTPOptions, TlsMode, WarpgateError}; +use warpgate_common::{ + configure_tls_connector, try_block, TargetHTTPOptions, TlsMode, WarpgateError, +}; use warpgate_web::lookup_built_file; use crate::common::{SessionAuthorization, SessionExt}; diff --git a/warpgate-web/src/gateway/lib/openapi-schema.json b/warpgate-web/src/gateway/lib/openapi-schema.json index 8e825db8..128f410e 100644 --- a/warpgate-web/src/gateway/lib/openapi-schema.json +++ b/warpgate-web/src/gateway/lib/openapi-schema.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "title": "Warpgate HTTP proxy", - "version": "v0.14.1-8-g2ef3553-modified" + "version": "v0.14.1-13-ge76df34-modified" }, "servers": [ { @@ -11,21 +11,6 @@ ], "tags": [], "paths": { - "/__stub__": { - "get": { - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "AnySecurityScheme": [] - } - ], - "operationId": "__stub__" - } - }, "/auth/login": { "post": { "requestBody": { @@ -304,7 +289,10 @@ }, "security": [ { - "AnySecurityScheme": [] + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_targets" @@ -451,7 +439,10 @@ }, "security": [ { - "AnySecurityScheme": [] + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "get_my_credentials" @@ -486,7 +477,10 @@ }, "security": [ { - "AnySecurityScheme": [] + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "change_my_password" @@ -521,7 +515,10 @@ }, "security": [ { - "AnySecurityScheme": [] + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "add_my_public_key" @@ -555,7 +552,10 @@ }, "security": [ { - "AnySecurityScheme": [] + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_my_public_key" @@ -590,7 +590,10 @@ }, "security": [ { - "AnySecurityScheme": [] + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "add_my_otp" @@ -624,7 +627,10 @@ }, "security": [ { - "AnySecurityScheme": [] + "TokenSecurityScheme": [] + }, + { + "CookieSecurityScheme": [] } ], "operationId": "delete_my_otp" @@ -1214,7 +1220,12 @@ } }, "securitySchemes": { - "AnySecurityScheme": { + "CookieSecurityScheme": { + "type": "apiKey", + "name": "warpgate-http-session", + "in": "cookie" + }, + "TokenSecurityScheme": { "type": "apiKey", "name": "X-Warpgate-Token", "in": "header"