fixed #1104 - SNI support (#1402)

This commit is contained in:
Eugene 2025-07-02 08:11:22 +02:00 committed by GitHub
parent b61e06ca48
commit 8ff3e9a5cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 312 additions and 58 deletions

89
Cargo.lock generated
View file

@ -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"

View file

@ -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": {

View file

@ -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"

View file

@ -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<SniCertificateConfig>,
}
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![],
}
}
}

View file

@ -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<u8>,
certificates: Vec<CertificateDer<'static>>,
}
#[derive(Clone)]
pub struct TlsPrivateKey {
bytes: Vec<u8>,
key: Arc<dyn SigningKey>,
}
#[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<Vec<String>, 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<TlsCertificateAndPrivateKey> 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<R: IntoTlsCertificateRelativePaths>(
from: &R,
config: &WarpgateConfig,
) -> Result<TlsCertificateAndPrivateKey, RustlsSetupError> {
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?,
})
}

View file

@ -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),
}

View file

@ -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);
});

View file

@ -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::<MemoryStorage>::default())))
}
async fn make_rustls_config(config: &WarpgateConfig) -> Result<RustlsConfig> {
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(())
}

View file

@ -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};

View file

@ -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"