Skip to content

Commit a745087

Browse files
Merge pull request #4595 from zeroclaw-labs/feat/mtls-gateway
feat(gateway): mutual TLS (mTLS) for high-security deployments
2 parents 55d8345 + fbd19a2 commit a745087

File tree

6 files changed

+631
-11
lines changed

6 files changed

+631
-11
lines changed

Cargo.lock

Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ nostr-sdk = { version = "0.44", default-features = false, features = ["nip04", "
156156
regex = "1.10"
157157
hostname = "0.4.2"
158158
rustls = "0.23"
159+
rustls-pemfile = "2"
159160
rustls-pki-types = "1.14.0"
160161
tokio-rustls = "0.26.4"
161162
webpki-roots = "1.0.6"
@@ -167,7 +168,9 @@ async-imap = { version = "0.11",features = ["runtime-tokio"], default-features =
167168

168169
# HTTP server (gateway) — replaces raw TCP for proper HTTP/1.1 compliance
169170
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] }
170-
tower = { version = "0.5", default-features = false }
171+
hyper = { version = "1", features = ["http1", "server"] }
172+
hyper-util = { version = "0.1", features = ["tokio", "server-auto", "server-graceful"] }
173+
tower = { version = "0.5", default-features = false, features = ["util"] }
171174
tower-http = { version = "0.6", default-features = false, features = ["limit", "timeout"] }
172175
http-body-util = "0.1"
173176

@@ -310,6 +313,7 @@ tempfile = "3.26"
310313
criterion = { version = "0.8", features = ["async_tokio"] }
311314
wiremock = "0.6"
312315
scopeguard = "1.2"
316+
rcgen = "0.13"
313317

314318
[[test]]
315319
name = "component"

examples/config.example.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,14 @@ allow_override = false
117117
# Also transcribe non-PTT (forwarded / regular) audio on WhatsApp.
118118
# Default: false (only voice notes are transcribed).
119119
# transcribe_non_ptt_audio = false
120+
121+
# ── Gateway TLS / mTLS Configuration ──────────────────────────
122+
# [gateway.tls]
123+
# enabled = false
124+
# cert_path = "/path/to/server.crt"
125+
# key_path = "/path/to/server.key"
126+
# [gateway.tls.client_auth]
127+
# enabled = false
128+
# ca_cert_path = "/path/to/ca.crt"
129+
# require_client_cert = true
130+
# pinned_certs = []

src/config/schema.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,6 +1919,10 @@ pub struct GatewayConfig {
19191919
/// Pairing dashboard configuration
19201920
#[serde(default)]
19211921
pub pairing_dashboard: PairingDashboardConfig,
1922+
1923+
/// TLS configuration for the gateway server (`[gateway.tls]`).
1924+
#[serde(default)]
1925+
pub tls: Option<GatewayTlsConfig>,
19221926
}
19231927

19241928
fn default_gateway_port() -> u16 {
@@ -1975,6 +1979,7 @@ impl Default for GatewayConfig {
19751979
session_persistence: true,
19761980
session_ttl_hours: 0,
19771981
pairing_dashboard: PairingDashboardConfig::default(),
1982+
tls: None,
19781983
}
19791984
}
19801985
}
@@ -2027,6 +2032,38 @@ impl Default for PairingDashboardConfig {
20272032
}
20282033
}
20292034

2035+
/// TLS configuration for the gateway server (`[gateway.tls]`).
2036+
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2037+
pub struct GatewayTlsConfig {
2038+
/// Enable TLS for the gateway (default: false).
2039+
#[serde(default)]
2040+
pub enabled: bool,
2041+
/// Path to the PEM-encoded server certificate file.
2042+
pub cert_path: String,
2043+
/// Path to the PEM-encoded server private key file.
2044+
pub key_path: String,
2045+
/// Client certificate authentication (mutual TLS) settings.
2046+
#[serde(default)]
2047+
pub client_auth: Option<GatewayClientAuthConfig>,
2048+
}
2049+
2050+
/// Client certificate authentication (mTLS) configuration (`[gateway.tls.client_auth]`).
2051+
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2052+
pub struct GatewayClientAuthConfig {
2053+
/// Enable client certificate verification (default: false).
2054+
#[serde(default)]
2055+
pub enabled: bool,
2056+
/// Path to the PEM-encoded CA certificate used to verify client certs.
2057+
pub ca_cert_path: String,
2058+
/// Reject connections that do not present a valid client certificate (default: true).
2059+
#[serde(default = "default_true")]
2060+
pub require_client_cert: bool,
2061+
/// Optional SHA-256 fingerprints for certificate pinning.
2062+
/// When non-empty, only client certs matching one of these fingerprints are accepted.
2063+
#[serde(default)]
2064+
pub pinned_certs: Vec<String>,
2065+
}
2066+
20302067
/// Secure transport configuration for inter-node communication (`[node_transport]`).
20312068
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
20322069
pub struct NodeTransportConfig {
@@ -12384,6 +12421,7 @@ channel_ids = ["C123", "D456"]
1238412421
session_persistence: true,
1238512422
session_ttl_hours: 0,
1238612423
pairing_dashboard: PairingDashboardConfig::default(),
12424+
tls: None,
1238712425
};
1238812426
let toml_str = toml::to_string(&g).unwrap();
1238912427
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();

src/gateway/mod.rs

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub mod canvas;
1515
pub mod nodes;
1616
pub mod sse;
1717
pub mod static_files;
18+
pub mod tls;
1819
pub mod ws;
1920

2021
use crate::channels::{
@@ -961,16 +962,81 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
961962
inner
962963
};
963964

964-
// Run the server with graceful shutdown
965-
axum::serve(
966-
listener,
967-
app.into_make_service_with_connect_info::<SocketAddr>(),
968-
)
969-
.with_graceful_shutdown(async move {
970-
let _ = shutdown_rx.changed().await;
971-
tracing::info!("🦀 ZeroClaw Gateway shutting down...");
972-
})
973-
.await?;
965+
// ── TLS / mTLS setup ───────────────────────────────────────────
966+
let tls_acceptor = match &config.gateway.tls {
967+
Some(tls_cfg) if tls_cfg.enabled => {
968+
let has_mtls = tls_cfg.client_auth.as_ref().is_some_and(|ca| ca.enabled);
969+
if has_mtls {
970+
tracing::info!("TLS enabled with mutual TLS (mTLS) client verification");
971+
} else {
972+
tracing::info!("TLS enabled (no client certificate requirement)");
973+
}
974+
Some(tls::build_tls_acceptor(tls_cfg)?)
975+
}
976+
_ => None,
977+
};
978+
979+
if let Some(tls_acceptor) = tls_acceptor {
980+
// Manual TLS accept loop — serves each connection via hyper.
981+
let app = app.into_make_service_with_connect_info::<SocketAddr>();
982+
let mut app = app;
983+
984+
let mut shutdown_signal = shutdown_rx;
985+
loop {
986+
tokio::select! {
987+
conn = listener.accept() => {
988+
let (tcp_stream, remote_addr) = conn?;
989+
let tls_acceptor = tls_acceptor.clone();
990+
let svc = tower::MakeService::<
991+
SocketAddr,
992+
hyper::Request<hyper::body::Incoming>,
993+
>::make_service(&mut app, remote_addr)
994+
.await
995+
.expect("infallible make_service");
996+
997+
tokio::spawn(async move {
998+
let tls_stream = match tls_acceptor.accept(tcp_stream).await {
999+
Ok(s) => s,
1000+
Err(e) => {
1001+
tracing::debug!("TLS handshake failed from {remote_addr}: {e}");
1002+
return;
1003+
}
1004+
};
1005+
let io = hyper_util::rt::TokioIo::new(tls_stream);
1006+
let hyper_svc = hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
1007+
let mut svc = svc.clone();
1008+
async move {
1009+
tower::Service::call(&mut svc, req).await
1010+
}
1011+
});
1012+
if let Err(e) = hyper_util::server::conn::auto::Builder::new(
1013+
hyper_util::rt::TokioExecutor::new(),
1014+
)
1015+
.serve_connection(io, hyper_svc)
1016+
.await
1017+
{
1018+
tracing::debug!("connection error from {remote_addr}: {e}");
1019+
}
1020+
});
1021+
}
1022+
_ = shutdown_signal.changed() => {
1023+
tracing::info!("🦀 ZeroClaw Gateway shutting down...");
1024+
break;
1025+
}
1026+
}
1027+
}
1028+
} else {
1029+
// Plain TCP — use axum's built-in serve.
1030+
axum::serve(
1031+
listener,
1032+
app.into_make_service_with_connect_info::<SocketAddr>(),
1033+
)
1034+
.with_graceful_shutdown(async move {
1035+
let _ = shutdown_rx.changed().await;
1036+
tracing::info!("🦀 ZeroClaw Gateway shutting down...");
1037+
})
1038+
.await?;
1039+
}
9741040

9751041
Ok(())
9761042
}

0 commit comments

Comments
 (0)