From 6e76f6d92da672cb4310deef1079fce4307b0fb4 Mon Sep 17 00:00:00 2001 From: Seth Jennings Date: Tue, 12 May 2026 20:35:49 -0500 Subject: [PATCH] feat(server): separate HTTPS from mTLS authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 89 ++++++++ crates/openshell-bootstrap/src/pki.rs | 3 + crates/openshell-core/src/config.rs | 30 +-- crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/src/cli.rs | 46 ++-- crates/openshell-server/src/compute/vm.rs | 8 +- crates/openshell-server/src/lib.rs | 10 +- crates/openshell-server/src/multiplex.rs | 106 ++++++++-- .../openshell-server/src/service_routing.rs | 4 +- crates/openshell-server/src/tls.rs | 71 ++++--- .../tests/edge_tunnel_auth.rs | 198 +++++++----------- .../tests/multiplex_tls_integration.rs | 51 ++++- .../openshell/ci/values-tls-disabled.yaml | 1 - .../helm/openshell/templates/statefulset.yaml | 10 +- deploy/helm/openshell/values.yaml | 7 +- .../kube/manifests/openshell-helmchart.yaml | 1 - deploy/man/openshell-gateway.8.md | 12 +- deploy/man/openshell-gateway.env.5.md | 8 +- deploy/rpm/CONFIGURATION.md | 3 +- e2e/rust/e2e-vm.sh | 3 +- rfc/0003-gateway-configuration/README.md | 1 - 21 files changed, 423 insertions(+), 240 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c55118a2a..8649ac218 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,6 +168,45 @@ dependencies = [ "password-hash", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "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.117", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -1200,6 +1239,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -3283,6 +3336,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "olpc-cjson" version = "0.1.4" @@ -3665,6 +3727,7 @@ dependencies = [ "tracing-subscriber", "uuid", "wiremock", + "x509-parser", ] [[package]] @@ -4771,6 +4834,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" @@ -7260,6 +7332,23 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/crates/openshell-bootstrap/src/pki.rs b/crates/openshell-bootstrap/src/pki.rs index fce12e07a..ed93850df 100644 --- a/crates/openshell-bootstrap/src/pki.rs +++ b/crates/openshell-bootstrap/src/pki.rs @@ -86,6 +86,9 @@ pub fn generate_pki(extra_sans: &[String]) -> Result { client_params .distinguished_name .push(DnType::CommonName, "openshell-client"); + client_params + .distinguished_name + .push(DnType::OrganizationalUnitName, "openshell-user"); let client_cert = client_params .signed_by(&client_key, &ca_cert, &ca_key) diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 9e287b090..cfa625139 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -315,10 +315,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). +/// Two modes are supported: +/// - **HTTPS with optional mTLS** (`client_ca_path = Some`): +/// Client certificates are validated against the given CA when presented, +/// but never required. Clients may connect with or without a certificate. +/// - **HTTPS-only** (`client_ca_path = None`): +/// Server-side TLS only; no client certificates are requested. +/// +/// In both modes, authentication is handled at the application layer +/// (e.g. OIDC bearer tokens). mTLS is an additional mechanism. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TlsConfig { /// Path to the TLS certificate file. @@ -327,16 +332,17 @@ pub struct TlsConfig { /// Path to the TLS private key file. 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, + /// Path to the CA certificate file for client certificate verification. + /// When `Some`, client certs signed by this CA are validated. + /// When `None`, the server does not request client certs. + #[serde(default)] + pub client_ca_path: Option, - /// 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). + /// When `true` and `client_ca_path` is `Some`, the TLS handshake rejects + /// connections that do not present a valid client certificate. + /// When `false`, client certificates are accepted but not required. #[serde(default)] - pub allow_unauthenticated: bool, + pub require_client_auth: bool, } /// OIDC (`OpenID` Connect) configuration for JWT-based authentication. diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 9cba99045..1592a4b22 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -88,6 +88,7 @@ petname = "2" ipnet = "2" tempfile = "3" nix = { workspace = true } +x509-parser = "0.16" [features] dev-settings = ["openshell-core/dev-settings"] diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 78ab4ca5f..e5f902959 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -9,7 +9,7 @@ use openshell_core::ComputeDriverKind; use openshell_core::config::{DEFAULT_DOCKER_NETWORK_NAME, DEFAULT_SERVER_PORT, DEFAULT_SSH_PORT}; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; -use tracing::info; +use tracing::{info, warn}; use tracing_subscriber::EnvFilter; use crate::certgen; @@ -238,12 +238,6 @@ struct RunArgs { #[arg(long, env = "OPENSHELL_DISABLE_TLS")] disable_tls: bool, - /// Disable gateway authentication (mTLS client certificate requirement). - /// When set, the TLS handshake accepts connections without a client - /// certificate. Ignored when --disable-tls is set. - #[arg(long, env = "OPENSHELL_DISABLE_GATEWAY_AUTH")] - disable_gateway_auth: bool, - /// OIDC issuer URL for JWT-based authentication. /// When set, the server validates `authorization: Bearer` tokens on gRPC /// requests against the issuer's JWKS endpoint. @@ -335,6 +329,15 @@ 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 has_oidc = args.oidc_issuer.is_some(); + + if args.disable_tls && has_client_ca { + return Err(miette::miette!( + "--disable-tls and --tls-client-ca are mutually exclusive. Client mTLS authentication requires that TLS be enabled." + )); + } + let tls = if args.disable_tls { None } else { @@ -346,16 +349,11 @@ 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, - allow_unauthenticated: args.disable_gateway_auth, + require_client_auth: has_client_ca && !has_oidc, + client_ca_path: args.tls_client_ca, }) }; @@ -461,9 +459,23 @@ async fn run_from_args(args: RunArgs) -> Result<()> { }; if args.disable_tls { - info!("TLS disabled — listening on plaintext HTTP"); - } else if args.disable_gateway_auth { - info!("Gateway auth disabled — accepting connections without client certificates"); + warn!("TLS disabled — listening on plaintext HTTP"); + } else { + info!("TLS enabled — listening on encrypted HTTPS"); + } + + if has_client_ca { + info!("mTLS authentication enabled"); + } + if has_oidc { + info!("OIDC authentication enabled"); + } + + if !has_client_ca && !has_oidc { + 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"); diff --git a/crates/openshell-server/src/compute/vm.rs b/crates/openshell-server/src/compute/vm.rs index 76f3b7325..a6b847bb3 100644 --- a/crates/openshell-server/src/compute/vm.rs +++ b/crates/openshell-server/src/compute/vm.rs @@ -599,8 +599,8 @@ mod tests { let config = Config::new(Some(TlsConfig { cert_path: server_cert, key_path: server_key, - client_ca_path: server_ca, - allow_unauthenticated: false, + client_ca_path: Some(server_ca), + require_client_auth: false, })) .with_grpc_endpoint("https://gateway.internal:8443"); @@ -635,8 +635,8 @@ mod tests { let config = Config::new(Some(TlsConfig { cert_path: server_cert.clone(), key_path: server_key.clone(), - client_ca_path: server_ca, - allow_unauthenticated: false, + client_ca_path: Some(server_ca), + require_client_auth: false, })) .with_grpc_endpoint("https://gateway.internal:8443"); let vm_config = VmComputeConfig { diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 531519e92..8a466a9e1 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -289,8 +289,8 @@ pub async fn run_server( Some(TlsAcceptor::from_files( &tls.cert_path, &tls.key_path, - &tls.client_ca_path, - tls.allow_unauthenticated, + tls.client_ca_path.as_deref(), + tls.require_client_auth, )?) } else { info!("TLS disabled — accepting plaintext connections"); @@ -488,7 +488,11 @@ fn spawn_gateway_connection( Ok(ConnectionProtocol::Tls | ConnectionProtocol::Unknown) => { match acceptor.inner().accept(stream).await { Ok(tls_stream) => { - if let Err(e) = service.serve(tls_stream).await { + let peer_identity = multiplex::extract_peer_identity(&tls_stream); + if let Err(e) = service + .serve_with_peer_identity(tls_stream, peer_identity) + .await + { if is_benign_connection_close(e.as_ref()) { debug!(error = %e, client = %addr, "Connection closed"); } else { diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 51ae55a4d..deac9ee78 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -31,8 +31,8 @@ use tower_http::request_id::{MakeRequestId, RequestId}; use tracing::Span; use crate::{ - OpenShellService, ServerState, auth::authz::AuthzPolicy, auth::oidc, http_router, - inference::InferenceService, service_http_router, + OpenShellService, ServerState, auth::authz::AuthzPolicy, auth::identity::Identity, auth::oidc, + http_router, inference::InferenceService, service_http_router, }; /// Request-ID generator that produces a UUID v4 for each inbound request. @@ -129,6 +129,18 @@ impl MultiplexService { /// Serve a connection, routing to gRPC or HTTP based on content-type. pub async fn serve(&self, stream: S) -> Result<(), Box> + where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + self.serve_with_peer_identity(stream, None).await + } + + /// Serve a TLS connection with an optional mTLS peer identity. + pub async fn serve_with_peer_identity( + &self, + stream: S, + peer_identity: Option, + ) -> Result<(), Box> where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { @@ -141,10 +153,18 @@ impl MultiplexService { user_role: oidc.user_role.clone(), scopes_enabled: !oidc.scopes_claim.is_empty(), }); + let has_client_ca = self + .state + .config + .tls + .as_ref() + .is_some_and(|tls| tls.client_ca_path.is_some()); let grpc_service = AuthGrpcRouter::new( GrpcRouter::new(openshell, inference), self.state.oidc_cache.clone(), authz_policy, + has_client_ca, + peer_identity, ); let http_service = http_router(self.state.clone()); @@ -153,13 +173,6 @@ impl MultiplexService { let service = MultiplexedService::new(grpc_service, http_service); - // HTTP/2 adaptive flow control. Default windows (64 KiB / 64 KiB) - // throttle the RelayStream data plane to ~500 Mbps on LAN. Instead - // of committing to a fixed large window (which worst-case pins - // `max_concurrent_streams × stream_window` bytes per connection), - // we let hyper/h2 auto-size based on the measured bandwidth-delay - // product. Idle streams stay tiny; busy bulk streams grow as - // needed. Overrides any fixed initial_*_window_size settings. let mut builder = Builder::new(TokioExecutor::new()); builder.http2().adaptive_window(true); @@ -263,6 +276,10 @@ pub struct AuthGrpcRouter { inner: S, oidc_cache: Option>, authz_policy: Option, + /// Whether a client CA is configured (mTLS is a valid auth mechanism). + has_client_ca: bool, + /// mTLS peer identity extracted from the TLS handshake. + peer_identity: Option, } impl AuthGrpcRouter { @@ -270,11 +287,15 @@ impl AuthGrpcRouter { inner: S, oidc_cache: Option>, authz_policy: Option, + has_client_ca: bool, + peer_identity: Option, ) -> Self { Self { inner, oidc_cache, authz_policy, + has_client_ca, + peer_identity, } } } @@ -300,17 +321,26 @@ where fn call(&mut self, req: Request) -> Self::Future { let oidc_cache = self.oidc_cache.clone(); let authz_policy = self.authz_policy.clone(); + let has_client_ca = self.has_client_ca; + let peer_identity = self.peer_identity.clone(); let mut inner = self.inner.clone(); Box::pin(async move { let mut req = req; oidc::clear_internal_auth_markers(req.headers_mut()); - // If OIDC is not configured, pass through directly. - let Some(cache) = oidc_cache else { + // No auth configured — pass through. + if oidc_cache.is_none() && !has_client_ca { return inner.ready().await?.call(req).await; - }; + } + // mTLS-only (no OIDC) — TLS layer already enforced client certs, + // so if we got here the peer is authenticated. + if oidc_cache.is_none() && has_client_ca { + return inner.ready().await?.call(req).await; + } + + let cache = oidc_cache.expect("checked above"); let path = req.uri().path().to_string(); // Health probes and reflection — truly unauthenticated. @@ -342,9 +372,22 @@ where .and_then(|v| v.strip_prefix("Bearer ")); let Some(token) = token else { + // No bearer token — fall back to mTLS if a client cert was + // presented (only possible when both OIDC and client CA are + // configured and require_client_auth is false). + if let Some(ref identity) = peer_identity { + if let Some(ref policy) = authz_policy + && let Err(status) = policy.check(identity, &path) + { + let response = status.into_http(); + let (parts, body) = response.into_parts(); + let body = tonic::body::BoxBody::new(body); + return Ok(Response::from_parts(parts, body)); + } + return inner.ready().await?.call(req).await; + } let status = tonic::Status::unauthenticated("missing authorization header"); let response = status.into_http(); - // Convert the response body type. let (parts, body) = response.into_parts(); let body = tonic::body::BoxBody::new(body); return Ok(Response::from_parts(parts, body)); @@ -521,6 +564,43 @@ impl Body for BoxBody { } } +/// Extract an [`Identity`] from the peer certificates presented during a TLS +/// handshake. Returns `None` if no client certificate was presented. +pub fn extract_peer_identity(tls_stream: &tokio_rustls::server::TlsStream) -> Option +where + S: AsyncRead + AsyncWrite + Unpin, +{ + use crate::auth::identity::IdentityProvider; + use x509_parser::prelude::*; + + let (_, server_conn) = tls_stream.get_ref(); + let certs = server_conn.peer_certificates()?; + let first = certs.first()?; + + let (_, cert) = X509Certificate::from_der(first.as_ref()).ok()?; + let subject = cert.subject(); + + let cn = subject + .iter_common_name() + .next() + .and_then(|attr| attr.as_str().ok()) + .unwrap_or("unknown") + .to_string(); + + let roles: Vec = subject + .iter_organizational_unit() + .filter_map(|attr| attr.as_str().ok().map(String::from)) + .collect(); + + Some(Identity { + subject: cn.clone(), + display_name: Some(cn), + roles, + scopes: Vec::new(), + provider: IdentityProvider::Mtls, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 194f10417..5615d0f15 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -826,8 +826,8 @@ mod tests { openshell_core::TlsConfig { cert_path: "server.crt".into(), key_path: "server.key".into(), - client_ca_path: "ca.crt".into(), - allow_unauthenticated: false, + client_ca_path: Some("ca.crt".into()), + require_client_auth: false, } } diff --git a/crates/openshell-server/src/tls.rs b/crates/openshell-server/src/tls.rs index 95c18608f..1af1ce0cd 100644 --- a/crates/openshell-server/src/tls.rs +++ b/crates/openshell-server/src/tls.rs @@ -19,17 +19,19 @@ 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` and `require_client_auth` is `true`, + /// the TLS handshake rejects connections that do not present a valid + /// client certificate signed by the given CA. /// - /// 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 `Some` and `require_client_auth` is `false`, + /// client certificates are validated against the CA but not required. + /// Clients may connect without a certificate; presented certs from an + /// unknown CA are still rejected. + /// + /// When `client_ca_path` is `None`, the server does not request client + /// certificates at all (HTTPS-only). /// /// # Errors /// @@ -37,33 +39,40 @@ impl TlsAcceptor { pub fn from_files( cert_path: &Path, key_path: &Path, - client_ca_path: &Path, - allow_unauthenticated: bool, + client_ca_path: Option<&Path>, + require_client_auth: bool, ) -> Result { 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 require_client_auth { + verifier_builder + } else { + verifier_builder.allow_unauthenticated() + } + .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 diff --git a/crates/openshell-server/tests/edge_tunnel_auth.rs b/crates/openshell-server/tests/edge_tunnel_auth.rs index a4676232e..5cbf41b9a 100644 --- a/crates/openshell-server/tests/edge_tunnel_auth.rs +++ b/crates/openshell-server/tests/edge_tunnel_auth.rs @@ -12,18 +12,17 @@ //! //! 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` | client cert | bearer header | expected | +//! |-------------|-------------|---------------|---------------------------| +//! | Some | valid | — | OK (cert validated) | +//! | Some | none | — | OK (cert optional) | +//! | Some | none | present | OK (bearer auth) | +//! | Some | rogue CA | — | rejected (bad cert) | +//! | None | none | — | OK (HTTPS-only) | //! -//! (*) Simulates the edge tunnel path: no client cert but a JWT header. -//! (**) TLS handshake succeeds, but in production the auth middleware (not yet -//! implemented) would reject. This test proves the TLS layer alone does -//! not block unauthenticated connections when the flag is set. +//! Client certificates are always optional when a CA is configured. They are +//! validated when present (rogue-CA certs are rejected) but never required. +//! Authentication is handled at the application layer (OIDC bearer tokens). use bytes::Bytes; use http_body_util::Empty; @@ -675,17 +674,17 @@ fn https_client_no_cert( // Tests // =========================================================================== -/// Baseline: with `allow_unauthenticated=false` (default), mTLS connections work. +/// Valid client cert is accepted when a CA is configured. #[tokio::test] -async fn baseline_mtls_works_with_mandatory_client_certs() { +async fn mtls_valid_client_cert_accepted() { 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"), - &temp.path().join("ca.pem"), - false, // mandatory mTLS + Some(temp.path().join("ca.pem").as_path()), + false, ) .unwrap(); @@ -715,102 +714,18 @@ async fn baseline_mtls_works_with_mandatory_client_certs() { server.abort(); } -/// Baseline: with `allow_unauthenticated=false`, no-client-cert connections are -/// rejected at the TLS layer. +/// No client cert is accepted when a CA is configured — client certs are +/// always optional. Auth is deferred to the application layer. #[tokio::test] -async fn baseline_no_cert_rejected_with_mandatory_mtls() { +async fn no_client_cert_accepted_with_ca_configured() { 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"), - &temp.path().join("ca.pem"), - false, // mandatory mTLS - ) - .unwrap(); - - let (addr, server) = start_test_server(tls_acceptor).await; - - let ca_cert = tonic::transport::Certificate::from_pem(pki.ca_cert_pem.clone()); - let tls = ClientTlsConfig::new() - .ca_certificate(ca_cert) - .domain_name("localhost"); - let endpoint = Endpoint::from_shared(format!("https://localhost:{}", addr.port())) - .expect("invalid endpoint") - .tls_config(tls) - .expect("failed to set tls"); - - let result = endpoint.connect().await; - if let Ok(channel) = result { - let mut client = OpenShellClient::new(channel); - let rpc_result = client.health(HealthRequest {}).await; - assert!( - rpc_result.is_err(), - "expected RPC to fail without client cert when mTLS is mandatory" - ); - } - // If connect() itself failed, that's also correct — TLS handshake rejected. - - server.abort(); -} - -/// With `allow_unauthenticated=true`, mTLS connections still work (dual-auth). -#[tokio::test] -async fn dual_auth_mtls_still_accepted() { - 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"), - &temp.path().join("ca.pem"), - true, // allow unauthenticated (tunnel mode) - ) - .unwrap(); - - let (addr, server) = start_test_server(tls_acceptor).await; - - // gRPC with mTLS should still work - let mut grpc = grpc_client_mtls( - addr, - pki.ca_cert_pem.clone(), - pki.client_cert_pem.clone(), - pki.client_key_pem.clone(), - ) - .await; - let resp = grpc.health(HealthRequest {}).await.unwrap(); - assert_eq!(resp.get_ref().status, ServiceStatus::Healthy as i32); - - // HTTP with mTLS should still work - let client = https_client_mtls(&pki); - let req = Request::builder() - .method("GET") - .uri(format!("https://localhost:{}/healthz", addr.port())) - .body(Empty::::new()) - .unwrap(); - let resp = client.request(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - - server.abort(); -} - -/// With `allow_unauthenticated=true`, no-client-cert connections pass the TLS -/// handshake. This simulates Cloudflare Tunnel re-originating a connection. -/// -/// The gRPC health check succeeds because there is no auth middleware yet — -/// this proves the TLS layer is no longer the gate. When auth middleware is -/// added, the test should be updated to expect 401 without a valid JWT. -#[tokio::test] -async fn tunnel_mode_no_cert_passes_tls_handshake() { - 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"), - &temp.path().join("ca.pem"), - true, // allow unauthenticated (tunnel mode) + Some(temp.path().join("ca.pem").as_path()), + false, ) .unwrap(); @@ -822,7 +737,7 @@ async fn tunnel_mode_no_cert_passes_tls_handshake() { assert_eq!( resp.get_ref().status, ServiceStatus::Healthy as i32, - "gRPC health check should succeed without client cert in tunnel mode" + "gRPC health check should succeed without client cert" ); // HTTP without client cert @@ -836,28 +751,24 @@ async fn tunnel_mode_no_cert_passes_tls_handshake() { assert_eq!( resp.status(), StatusCode::OK, - "HTTP health check should succeed without client cert in tunnel mode" + "HTTP health check should succeed without client cert" ); server.abort(); } -/// Simulate the steady-state Cloudflare tunnel flow: no client cert, but the -/// `cf-authorization` header carries a token. At the TLS level this must -/// succeed; the header is passed through to the gRPC handler. -/// -/// Note: We use a dummy token value here. When real JWT verification middleware -/// is added, this test should use a properly-signed test JWT. +/// Bearer auth header passes through to the gRPC handler when no client +/// cert is presented. #[tokio::test] -async fn tunnel_mode_cf_authorization_header_reaches_server() { +async fn bearer_header_reaches_server_without_client_cert() { 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"), - &temp.path().join("ca.pem"), - true, + Some(temp.path().join("ca.pem").as_path()), + false, ) .unwrap(); @@ -871,24 +782,24 @@ async fn tunnel_mode_cf_authorization_header_reaches_server() { assert_eq!( resp.get_ref().status, ServiceStatus::Healthy as i32, - "gRPC with cf-authorization header should succeed in tunnel mode" + "gRPC with bearer header should succeed without client cert" ); server.abort(); } -/// With `allow_unauthenticated=true`, a client cert from a rogue CA is still -/// rejected by the TLS layer — the verifier still validates presented certs. +/// A client cert from a rogue CA is rejected at the TLS layer even though +/// client certs are optional — presented certs are still validated. #[tokio::test] -async fn tunnel_mode_rogue_cert_still_rejected() { +async fn rogue_cert_rejected() { 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"), - &temp.path().join("ca.pem"), - true, + Some(temp.path().join("ca.pem").as_path()), + false, ) .unwrap(); @@ -936,10 +847,53 @@ async fn tunnel_mode_rogue_cert_still_rejected() { let rpc_result = client.health(HealthRequest {}).await; assert!( rpc_result.is_err(), - "expected RPC to fail with rogue client cert even in tunnel mode" + "expected RPC to fail with rogue client cert" ); } // If connect() itself failed, that's also correct. 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::::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(); +} diff --git a/crates/openshell-server/tests/multiplex_tls_integration.rs b/crates/openshell-server/tests/multiplex_tls_integration.rs index 289f608f1..8e6ae11ac 100644 --- a/crates/openshell-server/tests/multiplex_tls_integration.rs +++ b/crates/openshell-server/tests/multiplex_tls_integration.rs @@ -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(); @@ -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(); @@ -646,21 +646,56 @@ async fn mtls_valid_client_cert_accepted() { } #[tokio::test] -async fn mtls_no_client_cert_rejected() { +async fn no_client_cert_accepted_with_ca() { 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"), - &temp.path().join("ca.pem"), + Some(temp.path().join("ca.pem").as_path()), false, ) .unwrap(); let (addr, server) = start_test_server(tls_acceptor).await; - // Connect with CA trust but no client cert -- should be rejected. + // Connect with CA trust but no client cert — should succeed (certs are optional). + let ca_cert = tonic::transport::Certificate::from_pem(pki.ca_cert_pem.clone()); + let tls = ClientTlsConfig::new() + .ca_certificate(ca_cert) + .domain_name("localhost"); + let endpoint = Endpoint::from_shared(format!("https://localhost:{}", addr.port())) + .expect("invalid endpoint") + .tls_config(tls) + .expect("failed to set tls"); + + let channel = endpoint + .connect() + .await + .expect("should connect without client cert"); + let mut client = OpenShellClient::new(channel); + let response = client.health(HealthRequest {}).await.unwrap(); + assert_eq!(response.get_ref().status, ServiceStatus::Healthy as i32); + + server.abort(); +} + +#[tokio::test] +async fn no_client_cert_rejected_when_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"), + Some(temp.path().join("ca.pem").as_path()), + true, + ) + .unwrap(); + + let (addr, server) = start_test_server(tls_acceptor).await; + let ca_cert = tonic::transport::Certificate::from_pem(pki.ca_cert_pem.clone()); let tls = ClientTlsConfig::new() .ca_certificate(ca_cert) @@ -671,14 +706,12 @@ async fn mtls_no_client_cert_rejected() { .expect("failed to set tls"); let result = endpoint.connect().await; - // Connection should fail at the TLS handshake level or shortly after. - // The exact error depends on timing -- it may fail on connect or on first RPC. if let Ok(channel) = result { let mut client = OpenShellClient::new(channel); let rpc_result = client.health(HealthRequest {}).await; assert!( rpc_result.is_err(), - "expected RPC to fail without client cert" + "expected RPC to fail without client cert when mTLS is required" ); } @@ -693,7 +726,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(); diff --git a/deploy/helm/openshell/ci/values-tls-disabled.yaml b/deploy/helm/openshell/ci/values-tls-disabled.yaml index ea7c7900c..7a771a178 100644 --- a/deploy/helm/openshell/ci/values-tls-disabled.yaml +++ b/deploy/helm/openshell/ci/values-tls-disabled.yaml @@ -5,6 +5,5 @@ # Typical when a reverse proxy or tunnel terminates TLS at the edge. server: disableTls: true - disableGatewayAuth: true pkiInitJob: enabled: false diff --git a/deploy/helm/openshell/templates/statefulset.yaml b/deploy/helm/openshell/templates/statefulset.yaml index b9618d51a..3c805f056 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -113,14 +113,12 @@ spec: value: /etc/openshell-tls/server/tls.crt - name: OPENSHELL_TLS_KEY value: /etc/openshell-tls/server/tls.key + {{- if or .Values.server.tls.clientCaSecretName .Values.pkiInitJob.enabled (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) }} - name: OPENSHELL_TLS_CLIENT_CA value: /etc/openshell-tls/client-ca/ca.crt + {{- end }} - name: OPENSHELL_CLIENT_TLS_SECRET_NAME value: {{ .Values.server.tls.clientTlsSecretName | quote }} - {{- if .Values.server.disableGatewayAuth }} - - name: OPENSHELL_DISABLE_GATEWAY_AUTH - value: "true" - {{- end }} {{- end }} {{- if .Values.server.oidc.issuer }} {{- if .Values.server.oidc.caConfigMapName }} @@ -157,6 +155,7 @@ spec: - name: tls-cert mountPath: /etc/openshell-tls/server readOnly: true + {{- if or .Values.server.tls.clientCaSecretName .Values.pkiInitJob.enabled (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) }} - name: tls-client-ca mountPath: /etc/openshell-tls/client-ca readOnly: true @@ -166,6 +165,7 @@ spec: mountPath: /etc/openshell-tls/oidc-ca readOnly: true {{- end }} + {{- end }} ports: - name: grpc containerPort: {{ .Values.service.port }} @@ -208,6 +208,7 @@ spec: - name: tls-cert secret: secretName: {{ .Values.server.tls.certSecretName }} + {{- if or .Values.server.tls.clientCaSecretName .Values.pkiInitJob.enabled (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) }} - name: tls-client-ca secret: {{- if or .Values.pkiInitJob.enabled (and .Values.certManager.enabled .Values.certManager.clientCaFromServerTlsSecret) }} @@ -224,6 +225,7 @@ spec: configMap: name: {{ .Values.server.oidc.caConfigMapName }} {{- end }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 4aa9faf20..b502fea9d 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -119,10 +119,6 @@ server: # Linux 5.12+. When enabled, container UID 0 maps to an unprivileged host # UID and capabilities become namespaced. enableUserNamespaces: false - # Disable gateway authentication (mTLS client certificate requirement). - # Set to true when the gateway sits behind a reverse proxy (e.g. - # Cloudflare Tunnel) that terminates TLS. - disableGatewayAuth: false # Disable TLS entirely — the server listens on plaintext HTTP. # Set to true when a reverse proxy / tunnel terminates TLS at the edge. disableTls: false @@ -132,7 +128,8 @@ server: tls: # K8s secret (type kubernetes.io/tls) with tls.crt and tls.key for the server certSecretName: openshell-server-tls - # K8s secret with ca.crt for client certificate verification + # K8s secret with ca.crt for client certificate verification (mTLS). + # Set to "" to disable mTLS and run HTTPS-only (use OIDC for auth instead). clientCaSecretName: openshell-server-client-ca # K8s secret mounted into sandbox pods for mTLS to the server clientTlsSecretName: openshell-client-tls diff --git a/deploy/kube/manifests/openshell-helmchart.yaml b/deploy/kube/manifests/openshell-helmchart.yaml index ea4e370dc..40170d289 100644 --- a/deploy/kube/manifests/openshell-helmchart.yaml +++ b/deploy/kube/manifests/openshell-helmchart.yaml @@ -36,7 +36,6 @@ spec: sshGatewayHost: __SSH_GATEWAY_HOST__ sshGatewayPort: __SSH_GATEWAY_PORT__ hostGatewayIP: __HOST_GATEWAY_IP__ - disableGatewayAuth: __DISABLE_GATEWAY_AUTH__ disableTls: __DISABLE_TLS__ oidc: issuer: "__OIDC_ISSUER__" diff --git a/deploy/man/openshell-gateway.8.md b/deploy/man/openshell-gateway.8.md index f11a9d37b..14ca6a1ea 100644 --- a/deploy/man/openshell-gateway.8.md +++ b/deploy/man/openshell-gateway.8.md @@ -72,7 +72,11 @@ gRPC and HTTP, secured by mutual TLS (mTLS) by default. **--tls-client-ca** *PATH* : Path to CA certificate for client certificate verification (mTLS). - Required unless **--disable-tls** is set. + When set without **--oidc-issuer**, client certificates are required + and the TLS handshake rejects unauthenticated connections. When set + together with **--oidc-issuer**, client certificates are accepted + but not required — callers may authenticate with either a Bearer + token or a client certificate. Environment: **OPENSHELL_TLS_CLIENT_CA**. **--disable-tls** @@ -83,12 +87,6 @@ gRPC and HTTP, secured by mutual TLS (mTLS) by default. **--bind-address** to **127.0.0.1**. Environment: **OPENSHELL_DISABLE_TLS**. -**--disable-gateway-auth** -: Disable mTLS client certificate requirement. The TLS handshake - accepts connections without a client certificate. Ignored when - **--disable-tls** is set. - Environment: **OPENSHELL_DISABLE_GATEWAY_AUTH**. - **--server-san** *SAN* : Subject Alternative Name configured on the gateway server certificate. Repeat or pass a comma-separated value through diff --git a/deploy/man/openshell-gateway.env.5.md b/deploy/man/openshell-gateway.env.5.md index 50b9b0694..19da4cb4f 100644 --- a/deploy/man/openshell-gateway.env.5.md +++ b/deploy/man/openshell-gateway.env.5.md @@ -66,9 +66,6 @@ exist (the unit has built-in defaults for all required settings). **OPENSHELL_DB_URL** (default: sqlite://$XDG_STATE_HOME/openshell/gateway.db) : SQLite database URL for gateway state persistence. -**OPENSHELL_DISABLE_GATEWAY_AUTH** (default: unset) -: Set to **true** to disable mTLS client certificate verification. - ## TLS **OPENSHELL_TLS_CERT** (default: auto-generated path) @@ -78,7 +75,10 @@ exist (the unit has built-in defaults for all required settings). : Path to server TLS private key. **OPENSHELL_TLS_CLIENT_CA** (default: auto-generated path) -: Path to CA certificate for client certificate verification. +: Path to CA certificate for client certificate verification. When + set without **OPENSHELL_OIDC_ISSUER**, mTLS is required. When both + are set, callers may authenticate via Bearer token or client + certificate. **OPENSHELL_DISABLE_TLS** (default: unset) : Set to **true** to disable TLS entirely and listen on plaintext diff --git a/deploy/rpm/CONFIGURATION.md b/deploy/rpm/CONFIGURATION.md index de2e1e694..9e228600d 100644 --- a/deploy/rpm/CONFIGURATION.md +++ b/deploy/rpm/CONFIGURATION.md @@ -160,7 +160,6 @@ across package upgrades. | `OPENSHELL_LOG_LEVEL` | `info` | Log level: `trace`, `debug`, `info`, `warn`, `error` | | `OPENSHELL_DRIVERS` | `podman` | Compute driver (`podman`, `docker`, `kubernetes`) | | `OPENSHELL_DB_URL` | `sqlite://$XDG_STATE_HOME/openshell/gateway.db` | SQLite database URL for state persistence | -| `OPENSHELL_DISABLE_GATEWAY_AUTH` | (unset) | Set to `true` to skip mTLS client certificate checks | ### TLS settings @@ -168,7 +167,7 @@ across package upgrades. |----------|---------|-------------| | `OPENSHELL_TLS_CERT` | (auto-generated path) | Server TLS certificate | | `OPENSHELL_TLS_KEY` | (auto-generated path) | Server TLS private key | -| `OPENSHELL_TLS_CLIENT_CA` | (auto-generated path) | CA for client certificate verification | +| `OPENSHELL_TLS_CLIENT_CA` | (auto-generated path) | CA for client certificate verification; requires mTLS unless OIDC is also configured | | `OPENSHELL_DISABLE_TLS` | (unset) | Set to `true` to disable TLS | | `OPENSHELL_PODMAN_TLS_CA` | (auto-generated path) | CA cert mounted into sandbox containers | | `OPENSHELL_PODMAN_TLS_CERT` | (auto-generated path) | Client cert mounted into sandbox containers | diff --git a/e2e/rust/e2e-vm.sh b/e2e/rust/e2e-vm.sh index bd6611f63..6f3c1bac6 100755 --- a/e2e/rust/e2e-vm.sh +++ b/e2e/rust/e2e-vm.sh @@ -29,7 +29,7 @@ # 3. On macOS, codesigns the VM driver (libkrun needs the # `com.apple.security.hypervisor` entitlement). # 4. Starts the gateway with `--drivers vm --disable-tls -# --disable-gateway-auth --db-url sqlite::memory:` on a random +# --db-url sqlite::memory:` on a random # free port, waits for `Server listening`, then runs the # cluster-agnostic Rust smoke test. # 5. Tears the gateway down and (on failure) preserves the gateway @@ -176,7 +176,6 @@ echo "==> Starting openshell-gateway on 127.0.0.1:${HOST_PORT} (state: ${RUN_STA "${GATEWAY_BIN}" \ --drivers vm \ --disable-tls \ - --disable-gateway-auth \ --db-url 'sqlite::memory:' \ --port "${HOST_PORT}" \ --grpc-endpoint "http://host.containers.internal:${HOST_PORT}" \ diff --git a/rfc/0003-gateway-configuration/README.md b/rfc/0003-gateway-configuration/README.md index 773adda53..dd831e228 100644 --- a/rfc/0003-gateway-configuration/README.md +++ b/rfc/0003-gateway-configuration/README.md @@ -94,7 +94,6 @@ sandbox_ssh_port = 2222 cert_path = "/etc/openshell/certs/gateway.pem" key_path = "/etc/openshell/certs/gateway-key.pem" client_ca_path = "/etc/openshell/certs/client-ca.pem" -allow_unauthenticated = false # mirrors --disable-gateway-auth # ────────────────────────────────────────────────────────────────────────────── # OIDC — when omitted, JWT bearer auth is disabled