Skip to content

Commit 6e76f6d

Browse files
committed
feat(server): separate HTTPS from mTLS authentication
Make --tls-client-ca optional and make client certificates always optional when a CA is configured. This decouples HTTPS encryption from mTLS authentication, allowing mTLS and OIDC bearer tokens to coexist as parallel authentication mechanisms. When --tls-client-ca is provided, client certificates are validated against the CA when presented but never required. Clients may connect with or without a certificate — authentication is handled at the application layer (e.g. OIDC). Two TLS modes are now supported: - HTTPS with optional mTLS (--tls-client-ca provided) - HTTPS-only (--tls-client-ca omitted) The --disable-gateway-auth flag is preserved for backward compatibility but is now a no-op. The allow_unauthenticated field has been removed from TlsConfig. The Helm chart conditionally includes the client-ca volume and env var based on whether clientCaSecretName is configured.
1 parent 0dee90a commit 6e76f6d

21 files changed

Lines changed: 423 additions & 240 deletions

File tree

Cargo.lock

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

crates/openshell-bootstrap/src/pki.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ pub fn generate_pki(extra_sans: &[String]) -> Result<PkiBundle> {
8686
client_params
8787
.distinguished_name
8888
.push(DnType::CommonName, "openshell-client");
89+
client_params
90+
.distinguished_name
91+
.push(DnType::OrganizationalUnitName, "openshell-user");
8992

9093
let client_cert = client_params
9194
.signed_by(&client_key, &ca_cert, &ca_key)

crates/openshell-core/src/config.rs

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -315,10 +315,15 @@ pub struct ServiceRoutingConfig {
315315

316316
/// TLS configuration.
317317
///
318-
/// By default mTLS is enforced — all clients must present a certificate
319-
/// signed by the given CA. When `allow_unauthenticated` is `true`, the
320-
/// TLS handshake also accepts connections without a client certificate
321-
/// (needed for reverse-proxy deployments like Cloudflare Tunnel).
318+
/// Two modes are supported:
319+
/// - **HTTPS with optional mTLS** (`client_ca_path = Some`):
320+
/// Client certificates are validated against the given CA when presented,
321+
/// but never required. Clients may connect with or without a certificate.
322+
/// - **HTTPS-only** (`client_ca_path = None`):
323+
/// Server-side TLS only; no client certificates are requested.
324+
///
325+
/// In both modes, authentication is handled at the application layer
326+
/// (e.g. OIDC bearer tokens). mTLS is an additional mechanism.
322327
#[derive(Debug, Clone, Serialize, Deserialize)]
323328
pub struct TlsConfig {
324329
/// Path to the TLS certificate file.
@@ -327,16 +332,17 @@ pub struct TlsConfig {
327332
/// Path to the TLS private key file.
328333
pub key_path: PathBuf,
329334

330-
/// Path to the CA certificate file for client certificate verification (mTLS).
331-
/// The server requires all clients to present a valid certificate signed by
332-
/// this CA.
333-
pub client_ca_path: PathBuf,
335+
/// Path to the CA certificate file for client certificate verification.
336+
/// When `Some`, client certs signed by this CA are validated.
337+
/// When `None`, the server does not request client certs.
338+
#[serde(default)]
339+
pub client_ca_path: Option<PathBuf>,
334340

335-
/// When `true`, the TLS handshake succeeds even without a client
336-
/// certificate. Application-layer middleware must then enforce auth
337-
/// (e.g. via a CF JWT header).
341+
/// When `true` and `client_ca_path` is `Some`, the TLS handshake rejects
342+
/// connections that do not present a valid client certificate.
343+
/// When `false`, client certificates are accepted but not required.
338344
#[serde(default)]
339-
pub allow_unauthenticated: bool,
345+
pub require_client_auth: bool,
340346
}
341347

342348
/// OIDC (`OpenID` Connect) configuration for JWT-based authentication.

crates/openshell-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ petname = "2"
8888
ipnet = "2"
8989
tempfile = "3"
9090
nix = { workspace = true }
91+
x509-parser = "0.16"
9192

9293
[features]
9394
dev-settings = ["openshell-core/dev-settings"]

crates/openshell-server/src/cli.rs

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use openshell_core::ComputeDriverKind;
99
use openshell_core::config::{DEFAULT_DOCKER_NETWORK_NAME, DEFAULT_SERVER_PORT, DEFAULT_SSH_PORT};
1010
use std::net::{IpAddr, SocketAddr};
1111
use std::path::PathBuf;
12-
use tracing::info;
12+
use tracing::{info, warn};
1313
use tracing_subscriber::EnvFilter;
1414

1515
use crate::certgen;
@@ -238,12 +238,6 @@ struct RunArgs {
238238
#[arg(long, env = "OPENSHELL_DISABLE_TLS")]
239239
disable_tls: bool,
240240

241-
/// Disable gateway authentication (mTLS client certificate requirement).
242-
/// When set, the TLS handshake accepts connections without a client
243-
/// certificate. Ignored when --disable-tls is set.
244-
#[arg(long, env = "OPENSHELL_DISABLE_GATEWAY_AUTH")]
245-
disable_gateway_auth: bool,
246-
247241
/// OIDC issuer URL for JWT-based authentication.
248242
/// When set, the server validates `authorization: Bearer` tokens on gRPC
249243
/// requests against the issuer's JWKS endpoint.
@@ -335,6 +329,15 @@ async fn run_from_args(args: RunArgs) -> Result<()> {
335329

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

332+
let has_client_ca = args.tls_client_ca.is_some();
333+
let has_oidc = args.oidc_issuer.is_some();
334+
335+
if args.disable_tls && has_client_ca {
336+
return Err(miette::miette!(
337+
"--disable-tls and --tls-client-ca are mutually exclusive. Client mTLS authentication requires that TLS be enabled."
338+
));
339+
}
340+
338341
let tls = if args.disable_tls {
339342
None
340343
} else {
@@ -346,16 +349,11 @@ async fn run_from_args(args: RunArgs) -> Result<()> {
346349
let key_path = args.tls_key.ok_or_else(|| {
347350
miette::miette!("--tls-key is required when TLS is enabled (use --disable-tls to skip)")
348351
})?;
349-
let client_ca_path = args.tls_client_ca.ok_or_else(|| {
350-
miette::miette!(
351-
"--tls-client-ca is required when TLS is enabled (use --disable-tls to skip)"
352-
)
353-
})?;
354352
Some(openshell_core::TlsConfig {
355353
cert_path,
356354
key_path,
357-
client_ca_path,
358-
allow_unauthenticated: args.disable_gateway_auth,
355+
require_client_auth: has_client_ca && !has_oidc,
356+
client_ca_path: args.tls_client_ca,
359357
})
360358
};
361359

@@ -461,9 +459,23 @@ async fn run_from_args(args: RunArgs) -> Result<()> {
461459
};
462460

463461
if args.disable_tls {
464-
info!("TLS disabled — listening on plaintext HTTP");
465-
} else if args.disable_gateway_auth {
466-
info!("Gateway auth disabled — accepting connections without client certificates");
462+
warn!("TLS disabled — listening on plaintext HTTP");
463+
} else {
464+
info!("TLS enabled — listening on encrypted HTTPS");
465+
}
466+
467+
if has_client_ca {
468+
info!("mTLS authentication enabled");
469+
}
470+
if has_oidc {
471+
info!("OIDC authentication enabled");
472+
}
473+
474+
if !has_client_ca && !has_oidc {
475+
warn!(
476+
"Neither mTLS (--tls-client-ca) nor OIDC (--oidc-issuer) is configured — \
477+
the gateway has no authentication mechanism"
478+
);
467479
}
468480

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

crates/openshell-server/src/compute/vm.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -599,8 +599,8 @@ mod tests {
599599
let config = Config::new(Some(TlsConfig {
600600
cert_path: server_cert,
601601
key_path: server_key,
602-
client_ca_path: server_ca,
603-
allow_unauthenticated: false,
602+
client_ca_path: Some(server_ca),
603+
require_client_auth: false,
604604
}))
605605
.with_grpc_endpoint("https://gateway.internal:8443");
606606

@@ -635,8 +635,8 @@ mod tests {
635635
let config = Config::new(Some(TlsConfig {
636636
cert_path: server_cert.clone(),
637637
key_path: server_key.clone(),
638-
client_ca_path: server_ca,
639-
allow_unauthenticated: false,
638+
client_ca_path: Some(server_ca),
639+
require_client_auth: false,
640640
}))
641641
.with_grpc_endpoint("https://gateway.internal:8443");
642642
let vm_config = VmComputeConfig {

crates/openshell-server/src/lib.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,8 @@ pub async fn run_server(
289289
Some(TlsAcceptor::from_files(
290290
&tls.cert_path,
291291
&tls.key_path,
292-
&tls.client_ca_path,
293-
tls.allow_unauthenticated,
292+
tls.client_ca_path.as_deref(),
293+
tls.require_client_auth,
294294
)?)
295295
} else {
296296
info!("TLS disabled — accepting plaintext connections");
@@ -488,7 +488,11 @@ fn spawn_gateway_connection(
488488
Ok(ConnectionProtocol::Tls | ConnectionProtocol::Unknown) => {
489489
match acceptor.inner().accept(stream).await {
490490
Ok(tls_stream) => {
491-
if let Err(e) = service.serve(tls_stream).await {
491+
let peer_identity = multiplex::extract_peer_identity(&tls_stream);
492+
if let Err(e) = service
493+
.serve_with_peer_identity(tls_stream, peer_identity)
494+
.await
495+
{
492496
if is_benign_connection_close(e.as_ref()) {
493497
debug!(error = %e, client = %addr, "Connection closed");
494498
} else {

0 commit comments

Comments
 (0)