Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,10 +326,15 @@ pub struct ServiceRoutingConfig {

/// TLS configuration.
///
/// By default mTLS is enforced — all clients must present a certificate
/// signed by the given CA. When `allow_unauthenticated` is `true`, the
/// TLS handshake also accepts connections without a client certificate
/// (needed for reverse-proxy deployments like Cloudflare Tunnel).
/// Three modes are supported:
/// - **mTLS** (`client_ca_path = Some`, `allow_unauthenticated = false`):
/// All clients must present a valid certificate signed by the given CA.
/// - **Dual-auth / tunnel** (`client_ca_path = Some`, `allow_unauthenticated = true`):
/// The TLS handshake accepts connections with or without a client certificate.
/// Application-layer middleware must enforce auth (e.g. via a JWT header).
/// - **HTTPS-only** (`client_ca_path = None`):
/// Server-side TLS only; no client certificates are requested.
/// Authentication is delegated entirely to the application layer (e.g. OIDC).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsConfig {
/// Path to the TLS certificate file.
Expand All @@ -339,13 +344,12 @@ pub struct TlsConfig {
pub key_path: PathBuf,

/// Path to the CA certificate file for client certificate verification (mTLS).
/// The server requires all clients to present a valid certificate signed by
/// this CA.
pub client_ca_path: PathBuf,
/// When `None`, the server does not request client certificates (HTTPS-only).
#[serde(default)]
pub client_ca_path: Option<PathBuf>,

/// When `true`, the TLS handshake succeeds even without a client
/// certificate. Application-layer middleware must then enforce auth
/// (e.g. via a CF JWT header).
/// certificate. Only meaningful when `client_ca_path` is `Some`.
#[serde(default)]
pub allow_unauthenticated: bool,
}
Expand Down
19 changes: 12 additions & 7 deletions crates/openshell-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use openshell_core::config::{
};
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use tracing::info;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;

use crate::certgen;
Expand Down Expand Up @@ -345,6 +345,7 @@ async fn run_from_args(args: RunArgs) -> Result<()> {

let bind = SocketAddr::new(args.bind_address, args.port);

let has_client_ca = args.tls_client_ca.is_some();
let tls = if args.disable_tls {
None
} else {
Expand All @@ -356,15 +357,10 @@ async fn run_from_args(args: RunArgs) -> Result<()> {
let key_path = args.tls_key.ok_or_else(|| {
miette::miette!("--tls-key is required when TLS is enabled (use --disable-tls to skip)")
})?;
let client_ca_path = args.tls_client_ca.ok_or_else(|| {
miette::miette!(
"--tls-client-ca is required when TLS is enabled (use --disable-tls to skip)"
)
})?;
Some(openshell_core::TlsConfig {
cert_path,
key_path,
client_ca_path,
client_ca_path: args.tls_client_ca,
allow_unauthenticated: args.disable_gateway_auth,
})
};
Expand Down Expand Up @@ -477,10 +473,19 @@ async fn run_from_args(args: RunArgs) -> Result<()> {

if args.disable_tls {
info!("TLS disabled — listening on plaintext HTTP");
} else if !has_client_ca {
info!("HTTPS enabled (no mTLS) — client certificates not required");
} else if args.disable_gateway_auth {
info!("Gateway auth disabled — accepting connections without client certificates");
}

if !args.disable_tls && !has_client_ca && config.oidc.is_none() {
warn!(
"Neither mTLS (--tls-client-ca) nor OIDC (--oidc-issuer) is configured — \
the gateway has no authentication mechanism"
);
}

info!(bind = %config.bind_address, "Starting OpenShell server");

run_server(config, vm_config, docker_config, tracing_log_bus)
Expand Down
4 changes: 2 additions & 2 deletions crates/openshell-server/src/compute/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ mod tests {
let config = Config::new(Some(TlsConfig {
cert_path: server_cert,
key_path: server_key,
client_ca_path: server_ca,
client_ca_path: Some(server_ca),
allow_unauthenticated: false,
}))
.with_grpc_endpoint("https://gateway.internal:8443");
Expand Down Expand Up @@ -646,7 +646,7 @@ mod tests {
let config = Config::new(Some(TlsConfig {
cert_path: server_cert.clone(),
key_path: server_key.clone(),
client_ca_path: server_ca,
client_ca_path: Some(server_ca),
allow_unauthenticated: false,
}))
.with_grpc_endpoint("https://gateway.internal:8443");
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ pub async fn run_server(
Some(TlsAcceptor::from_files(
&tls.cert_path,
&tls.key_path,
&tls.client_ca_path,
tls.client_ca_path.as_deref(),
tls.allow_unauthenticated,
)?)
} else {
Expand Down
65 changes: 35 additions & 30 deletions crates/openshell-server/src/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,51 +19,56 @@ pub struct TlsAcceptor {
}

impl TlsAcceptor {
/// Create a new TLS acceptor from certificate, key, and client CA files.
/// Create a new TLS acceptor from certificate and key files.
///
/// When `allow_unauthenticated` is `false` (the default), the server
/// enforces mTLS — all clients must present a valid certificate signed
/// by the given CA.
/// When `client_ca_path` is `Some`, client certificate verification is
/// enabled (mTLS). The `allow_unauthenticated` flag then controls
/// whether a client cert is *required* (`false`) or *optional* (`true`).
///
/// When `allow_unauthenticated` is `true`, the TLS handshake succeeds
/// even without a client certificate. This is required when the server
/// sits behind a reverse proxy (e.g. Cloudflare Tunnel) that terminates
/// TLS and cannot forward client certificates. Application-layer
/// middleware must then enforce authentication (e.g. via a JWT header).
/// When `client_ca_path` is `None`, the server does not request client
/// certificates at all (HTTPS-only). Authentication must be handled at
/// the application layer (e.g. OIDC bearer tokens).
///
/// # Errors
///
/// Returns an error if the certificate, key, or CA files cannot be read or parsed.
pub fn from_files(
cert_path: &Path,
key_path: &Path,
client_ca_path: &Path,
client_ca_path: Option<&Path>,
allow_unauthenticated: bool,
) -> Result<Self> {
let certs = load_certs(cert_path)?;
let key = load_key(key_path)?;

let ca_certs = load_certs(client_ca_path)?;
let mut root_store = rustls::RootCertStore::empty();
for cert in ca_certs {
root_store
.add(cert)
.map_err(|e| Error::tls(format!("failed to add CA certificate: {e}")))?;
}

let verifier_builder = WebPkiClientVerifier::builder(Arc::new(root_store));
let verifier = if allow_unauthenticated {
verifier_builder.allow_unauthenticated()
let mut config = if let Some(ca_path) = client_ca_path {
let ca_certs = load_certs(ca_path)?;
let mut root_store = rustls::RootCertStore::empty();
for cert in ca_certs {
root_store
.add(cert)
.map_err(|e| Error::tls(format!("failed to add CA certificate: {e}")))?;
}

let verifier_builder = WebPkiClientVerifier::builder(Arc::new(root_store));
let verifier = if allow_unauthenticated {
verifier_builder.allow_unauthenticated()
} else {
verifier_builder
}
.build()
.map_err(|e| Error::tls(format!("failed to build client verifier: {e}")))?;

ServerConfig::builder()
.with_client_cert_verifier(verifier)
.with_single_cert(certs, key)
.map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))?
} else {
verifier_builder
}
.build()
.map_err(|e| Error::tls(format!("failed to build client verifier: {e}")))?;

let mut config = ServerConfig::builder()
.with_client_cert_verifier(verifier)
.with_single_cert(certs, key)
.map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))?;
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))?
};

config
.alpn_protocols
Expand Down
72 changes: 59 additions & 13 deletions crates/openshell-server/tests/edge_tunnel_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@
//!
//! Test matrix:
//!
//! | `allow_unauthenticated` | client cert | bearer auth header | expected |
//! |-----------------------|-------------|--------------------|----------|
//! | false | valid | — | OK |
//! | false | none | — | rejected |
//! | true | valid | — | OK |
//! | true | none | present | OK (*) |
//! | true | none | absent | OK (**) |
//! | `client_ca` | `allow_unauthenticated` | client cert | bearer header | expected |
//! |-------------|------------------------|-------------|---------------|----------|
//! | Some | false | valid | — | OK |
//! | Some | false | none | — | rejected |
//! | Some | true | valid | — | OK |
//! | Some | true | none | present | OK (*) |
//! | Some | true | none | absent | OK (**) |
//! | None | — | none | — | OK (***) |
//!
//! (***) HTTPS-only mode: no client certs requested at all.
//!
//! (*) Simulates the edge tunnel path: no client cert but a JWT header.
//! (**) TLS handshake succeeds, but in production the auth middleware (not yet
Expand Down Expand Up @@ -684,7 +687,7 @@ async fn baseline_mtls_works_with_mandatory_client_certs() {
let tls_acceptor = TlsAcceptor::from_files(
&temp.path().join("server-cert.pem"),
&temp.path().join("server-key.pem"),
&temp.path().join("ca.pem"),
Some(temp.path().join("ca.pem").as_path()),
false, // mandatory mTLS
)
.unwrap();
Expand Down Expand Up @@ -725,7 +728,7 @@ async fn baseline_no_cert_rejected_with_mandatory_mtls() {
let tls_acceptor = TlsAcceptor::from_files(
&temp.path().join("server-cert.pem"),
&temp.path().join("server-key.pem"),
&temp.path().join("ca.pem"),
Some(temp.path().join("ca.pem").as_path()),
false, // mandatory mTLS
)
.unwrap();
Expand Down Expand Up @@ -764,7 +767,7 @@ async fn dual_auth_mtls_still_accepted() {
let tls_acceptor = TlsAcceptor::from_files(
&temp.path().join("server-cert.pem"),
&temp.path().join("server-key.pem"),
&temp.path().join("ca.pem"),
Some(temp.path().join("ca.pem").as_path()),
true, // allow unauthenticated (tunnel mode)
)
.unwrap();
Expand Down Expand Up @@ -809,7 +812,7 @@ async fn tunnel_mode_no_cert_passes_tls_handshake() {
let tls_acceptor = TlsAcceptor::from_files(
&temp.path().join("server-cert.pem"),
&temp.path().join("server-key.pem"),
&temp.path().join("ca.pem"),
Some(temp.path().join("ca.pem").as_path()),
true, // allow unauthenticated (tunnel mode)
)
.unwrap();
Expand Down Expand Up @@ -856,7 +859,7 @@ async fn tunnel_mode_cf_authorization_header_reaches_server() {
let tls_acceptor = TlsAcceptor::from_files(
&temp.path().join("server-cert.pem"),
&temp.path().join("server-key.pem"),
&temp.path().join("ca.pem"),
Some(temp.path().join("ca.pem").as_path()),
true,
)
.unwrap();
Expand Down Expand Up @@ -887,7 +890,7 @@ async fn tunnel_mode_rogue_cert_still_rejected() {
let tls_acceptor = TlsAcceptor::from_files(
&temp.path().join("server-cert.pem"),
&temp.path().join("server-key.pem"),
&temp.path().join("ca.pem"),
Some(temp.path().join("ca.pem").as_path()),
true,
)
.unwrap();
Expand Down Expand Up @@ -943,3 +946,46 @@ async fn tunnel_mode_rogue_cert_still_rejected() {

server.abort();
}

/// HTTPS-only mode: no client CA configured, so the server never requests
/// client certificates. Clients connect with server-only TLS.
#[tokio::test]
async fn https_only_no_client_cert_required() {
install_rustls_provider();
let (temp, pki) = generate_pki();

let tls_acceptor = TlsAcceptor::from_files(
&temp.path().join("server-cert.pem"),
&temp.path().join("server-key.pem"),
None,
false,
)
.unwrap();

let (addr, server) = start_test_server(tls_acceptor).await;

// gRPC without client cert — should succeed (no client certs requested)
let mut grpc = grpc_client_no_cert(addr, pki.ca_cert_pem.clone()).await;
let resp = grpc.health(HealthRequest {}).await.unwrap();
assert_eq!(
resp.get_ref().status,
ServiceStatus::Healthy as i32,
"gRPC health check should succeed in HTTPS-only mode"
);

// HTTP without client cert
let client = https_client_no_cert(&pki.ca_cert_pem);
let req = Request::builder()
.method("GET")
.uri(format!("https://localhost:{}/healthz", addr.port()))
.body(Empty::<Bytes>::new())
.unwrap();
let resp = client.request(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"HTTP health check should succeed in HTTPS-only mode"
);

server.abort();
}
8 changes: 4 additions & 4 deletions crates/openshell-server/tests/multiplex_tls_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ async fn serves_grpc_and_http_over_tls_on_same_port() {
let tls_acceptor = TlsAcceptor::from_files(
&temp.path().join("server-cert.pem"),
&temp.path().join("server-key.pem"),
&temp.path().join("ca.pem"),
Some(temp.path().join("ca.pem").as_path()),
false,
)
.unwrap();
Expand Down Expand Up @@ -625,7 +625,7 @@ async fn mtls_valid_client_cert_accepted() {
let tls_acceptor = TlsAcceptor::from_files(
&temp.path().join("server-cert.pem"),
&temp.path().join("server-key.pem"),
&temp.path().join("ca.pem"),
Some(temp.path().join("ca.pem").as_path()),
false,
)
.unwrap();
Expand Down Expand Up @@ -653,7 +653,7 @@ async fn mtls_no_client_cert_rejected() {
let tls_acceptor = TlsAcceptor::from_files(
&temp.path().join("server-cert.pem"),
&temp.path().join("server-key.pem"),
&temp.path().join("ca.pem"),
Some(temp.path().join("ca.pem").as_path()),
false,
)
.unwrap();
Expand Down Expand Up @@ -693,7 +693,7 @@ async fn mtls_wrong_ca_client_cert_rejected() {
let tls_acceptor = TlsAcceptor::from_files(
&temp.path().join("server-cert.pem"),
&temp.path().join("server-key.pem"),
&temp.path().join("ca.pem"),
Some(temp.path().join("ca.pem").as_path()),
false,
)
.unwrap();
Expand Down
Loading
Loading