Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9c3a382
feat(helm): add optional BackendTLSPolicy for e2e TLS
bsquizz Aug 12, 2026
b4ede4b
feat(helm,server): auto-create backend CA ConfigMap in certgen hook
bsquizz Aug 13, 2026
f38730c
refactor(helm): add server.tls.enableMtls flag for mTLS control
bsquizz Aug 14, 2026
2dc41b2
docs(helm): clarify cert-manager backend CA ConfigMap workflow
bsquizz Aug 20, 2026
e16355e
fix(docs): remove incorrect external hostname requirement for Backend…
bsquizz Aug 20, 2026
c9220f3
docs: clarify ACME with LetsEncrypt reference
bsquizz Aug 20, 2026
4cfd5c9
docs(openshift): restructure end-to-end TLS options and clarify Gatew…
bsquizz Aug 20, 2026
1cb5d03
feat(helm): eliminate two-stage install for BackendTLSPolicy with cer…
bsquizz Aug 21, 2026
d9adfb1
feat(helm): add configurable timeout for certgen hook
bsquizz Aug 21, 2026
24665b9
docs(helm): document configurable certgen timeout
bsquizz Aug 21, 2026
e62933f
feat(helm): add configurable failure behavior for certgen timeout
bsquizz Aug 21, 2026
fd80ea8
feat(helm): change failOnTimeout default to true and add troubleshoot…
bsquizz Aug 21, 2026
d430e74
fix(helm): make cert-manager resources pre-install hooks to fix ordering
bsquizz Aug 21, 2026
fcc4fe8
feat(helm): add validation to prevent enableMtls with BackendTLSPolicy
bsquizz Aug 21, 2026
4d0a147
docs(helm): clarify pkiInitJob.timeoutSeconds polling behavior
bsquizz Aug 21, 2026
6103a13
docs(openshift): remove outdated two-stage install instructions
bsquizz Aug 21, 2026
a4331a8
fix(helm): make pkiInitJob.timeoutSeconds the actual polling duration
bsquizz Aug 21, 2026
b1a8139
docs(helm): update values.yaml and README with correct polling duration
bsquizz Aug 21, 2026
d1ff4da
docs(kubernetes): add OIDC configuration to helm install and CLI exam…
bsquizz Aug 21, 2026
7d8aaa9
docs(kubernetes): explicitly list OIDC client ID in gateway add examples
bsquizz Aug 21, 2026
cb4d05f
fix(helm): address PR review feedback for BackendTLSPolicy
bsquizz Aug 26, 2026
ea0d052
fix(docs): resolve markdown lint errors in helm README and kubernetes…
bsquizz Sep 4, 2026
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
29 changes: 29 additions & 0 deletions .agents/skills/helm-dev-environment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,35 @@ Envoy Gateway is already installed by Skaffold (the `envoy-gateway` Helm release
service for the proxy; klipper-lb binds it to hostPort 80, reachable via the
`8080:80` load balancer port mapping.

### BackendTLSPolicy (end-to-end TLS)

To enable end-to-end TLS between the Gateway proxy and the gateway pod, add
BackendTLSPolicy values to the Helm install:

```bash
helm upgrade --install openshell deploy/helm/openshell \
--set grpcRoute.enabled=true \
--set grpcRoute.backendTLSPolicy.enabled=true \
--set server.tls.enableMtls=false \
...
```

This requires `server.tls.enableMtls=false` because ingress proxies cannot
present client certificates to the backend. The certgen hook creates a backend
CA ConfigMap from the server Secret's `ca.crt` key. With cert-manager, a
separate post-install Job polls for the cert-manager-issued certificate (up to
`pkiInitJob.timeoutSeconds`); with built-in PKI the ConfigMap is created in the
same pre-install hook. The ConfigMap is reconciled on every upgrade so CA
rotations propagate automatically.

Key Helm values:
- `grpcRoute.backendTLSPolicy.enabled`: create the BackendTLSPolicy resource
- `grpcRoute.backendTLSPolicy.caCertificateConfigMapName`: override ConfigMap name
- `grpcRoute.backendTLSPolicy.hostname`: override backend validation hostname
- `server.tls.enableMtls`: must be `false` for BackendTLSPolicy
- `pkiInitJob.timeoutSeconds`: polling duration for cert-manager mode
- `pkiInitJob.failOnTimeout`: fail install if cert-manager times out

### Keycloak OIDC

One-time setup — only needed once per cluster lifetime:
Expand Down
19 changes: 19 additions & 0 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,25 @@ requested present -> generate and write. This guards continuity across restarts
and upgrades while still recovering cleanly if an operator deletes everything
and starts over.

When `grpcRoute.backendTLSPolicy.enabled=true`, the certgen hook also creates a
`ConfigMap` containing the CA certificate (`ca.crt`) used by the Gateway proxy
to validate the backend pod's TLS certificate. The CA is always read from the
authoritative server Secret (not the in-memory bundle) so that enabling
BackendTLSPolicy on an existing release uses the CA that actually signed the
server certificate. The ConfigMap is reconciled on every hook run: if the CA
changes (rotation, re-issue), the ConfigMap is updated in place. In built-in PKI
mode the ConfigMap is created in the same pre-install hook. In cert-manager
mode, a separate post-install/post-upgrade hook Job polls for the cert-manager-
issued server Secret and then creates or updates the ConfigMap, because
cert-manager Certificate resources are regular release objects applied after
pre-install hooks.

The `server.tls.enableMtls` value controls whether the gateway requires client
certificates. When `enableMtls` is `false`, the gateway runs HTTPS-only without
client certificate verification (use OIDC for identity instead). BackendTLSPolicy
requires `enableMtls=false` because the ingress proxy cannot present client
certificates to the backend.

Operators who manage TLS PKI with cert-manager enable `certManager.enabled`;
cert-manager takes precedence over built-in TLS generation and the chart still
renders the JWT-only hook. Operators who pre-create all TLS and JWT Secrets can
Expand Down
196 changes: 194 additions & 2 deletions crates/openshell-server/src/certgen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

use clap::Args;
use k8s_openapi::ByteString;
use k8s_openapi::api::core::v1::Secret;
use k8s_openapi::api::core::v1::{ConfigMap, Secret};
use kube::Client;
use kube::api::{Api, ObjectMeta, PostParams};
use miette::{IntoDiagnostic, Result, WrapErr};
Expand Down Expand Up @@ -78,6 +78,33 @@ pub struct CertgenArgs {
/// For local debugging.
#[arg(long)]
dry_run: bool,

/// Name of a `ConfigMap` to create containing the CA certificate (key: ca.crt)
/// for `BackendTLSPolicy` backend validation. The CA is always read from the
/// authoritative server Secret: --server-secret-name in full PKI mode,
/// --backend-ca-source-secret in --jwt-only mode.
#[arg(long, value_name = "NAME")]
backend_ca_configmap_name: Option<String>,

/// Name of an existing `Secret` containing a ca.crt key to populate the
/// backend CA `ConfigMap` from. Required with --jwt-only when
/// --backend-ca-configmap-name is set (typically the server TLS `Secret`
/// created by cert-manager).
#[arg(long, value_name = "NAME", requires = "backend_ca_configmap_name")]
backend_ca_source_secret: Option<String>,

/// Maximum time in seconds to poll for the backend CA source `Secret` when
/// using cert-manager. Defaults to 90 seconds. The Helm chart sets this to
/// (Job activeDeadlineSeconds - 30) to leave margin for `ConfigMap` creation.
#[arg(long, value_name = "SECONDS", default_value = "90")]
backend_ca_poll_timeout_seconds: u64,

/// Fail with an error if the backend CA source `Secret` is not found within
/// the polling timeout. When false (default), the hook succeeds with a warning
/// and the `ConfigMap` is not created. The Helm chart sets this based on
/// pkiInitJob.failOnTimeout.
#[arg(long)]
backend_ca_fail_on_timeout: bool,
}

pub async fn run(args: CertgenArgs) -> Result<()> {
Expand All @@ -97,7 +124,13 @@ pub async fn run(args: CertgenArgs) -> Result<()> {
run_local(dir, &args.server_sans)
} else {
let bundle = generate_pki(&args.server_sans)?;
run_kubernetes(&args, &bundle).await
run_kubernetes(&args, &bundle).await?;

if let Some(ref cm_name) = args.backend_ca_configmap_name {
create_backend_ca_configmap_if_needed(&args, cm_name).await?;
}

Ok(())
}
}

Expand Down Expand Up @@ -293,6 +326,165 @@ async fn create_tls_secrets(
Ok(())
}

fn extract_ca_from_secret(secret: &Secret, name: &str) -> Result<String> {
let data = secret
.data
.as_ref()
.ok_or_else(|| miette::miette!("secret {name} has no data"))?;
let ca = data
.get("ca.crt")
.ok_or_else(|| miette::miette!("secret {name} has no ca.crt key"))?;
String::from_utf8(ca.0.clone())
.into_diagnostic()
.wrap_err("ca.crt is not valid UTF-8")
}

async fn create_backend_ca_configmap_if_needed(
args: &CertgenArgs,
configmap_name: &str,
) -> Result<()> {
let namespace = args
.namespace
.as_deref()
.ok_or_else(|| miette::miette!("--namespace is required (or set POD_NAMESPACE)"))?;

let client = Client::try_default()
.await
.into_diagnostic()
.wrap_err("failed to construct Kubernetes client for backend CA ConfigMap")?;
let secret_api: Api<Secret> = Api::namespaced(client.clone(), namespace);
let cm_api: Api<ConfigMap> = Api::namespaced(client, namespace);

// Resolve the CA from the authoritative server Secret rather than the
// in-memory bundle so upgrades that enable BackendTLSPolicy on an
// existing release use the CA that actually signed the server cert.
let ca_pem = if let Some(source_secret) = &args.backend_ca_source_secret {
let poll_timeout = std::time::Duration::from_secs(args.backend_ca_poll_timeout_seconds);
let poll_interval = std::time::Duration::from_secs(2);
let start = std::time::Instant::now();

let secret = loop {
match secret_api
.get_opt(source_secret)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to read secret {source_secret}"))?
{
Some(secret) => break secret,
None if start.elapsed() >= poll_timeout => {
let msg = format!(
"Backend CA source secret {source_secret} not found after {timeout_secs}s; \
ConfigMap {configmap_name} not created. This is expected if cert-manager \
is still issuing the certificate.",
timeout_secs = poll_timeout.as_secs()
);
if args.backend_ca_fail_on_timeout {
return Err(miette::miette!(
"{msg} Install failed due to --backend-ca-fail-on-timeout."
));
}
warn!(
secret = %source_secret,
configmap = %configmap_name,
timeout_secs = poll_timeout.as_secs(),
"{msg} Run helm upgrade after the TLS secret exists or the BackendTLSPolicy \
will remain non-functional until the ConfigMap is created manually."
);
return Ok(());
}
None => {
if start.elapsed().as_secs().is_multiple_of(10) {
info!(
secret = %source_secret,
elapsed_secs = start.elapsed().as_secs(),
"Waiting for cert-manager to issue TLS certificate..."
);
}
tokio::time::sleep(poll_interval).await;
}
}
};

info!(
secret = %source_secret,
elapsed_secs = start.elapsed().as_secs(),
"cert-manager TLS certificate found."
);
extract_ca_from_secret(&secret, source_secret)?
} else if let Some(server_secret) = &args.server_secret_name {
let secret = secret_api
.get(server_secret)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to read server secret {server_secret}"))?;
extract_ca_from_secret(&secret, server_secret)?
} else {
return Err(miette::miette!(
"--backend-ca-source-secret or --server-secret-name is required \
with --backend-ca-configmap-name"
));
};

// Reconcile: create or update so the backend CA stays current across
// CA rotations and upgrades.
if let Some(existing) = cm_api
.get_opt(configmap_name)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to read configmap {configmap_name}"))?
{
let up_to_date = existing
.data
.as_ref()
.and_then(|d| d.get("ca.crt"))
.map(String::as_str)
== Some(&ca_pem);
if up_to_date {
info!(
namespace = %namespace,
configmap = %configmap_name,
"Backend CA ConfigMap is up-to-date, skipping."
);
return Ok(());
}
let mut updated = existing;
updated.data = Some(BTreeMap::from([("ca.crt".to_string(), ca_pem)]));
cm_api
.replace(configmap_name, &PostParams::default(), &updated)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to update configmap {configmap_name}"))?;
info!(
namespace = %namespace,
configmap = %configmap_name,
"Backend CA ConfigMap updated with current CA."
);
return Ok(());
}

let configmap = ConfigMap {
metadata: ObjectMeta {
name: Some(configmap_name.to_string()),
..Default::default()
},
data: Some(BTreeMap::from([("ca.crt".to_string(), ca_pem)])),
..Default::default()
};

cm_api
.create(&PostParams::default(), &configmap)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to create configmap {configmap_name}"))?;

info!(
namespace = %namespace,
configmap = %configmap_name,
"Backend CA ConfigMap created."
);
Ok(())
}

fn tls_secret(name: &str, crt_pem: &str, key_pem: &str, ca_pem: &str) -> Secret {
let mut data = BTreeMap::new();
data.insert(
Expand Down
53 changes: 53 additions & 0 deletions crates/openshell-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,59 @@ mod tests {
));
}

#[test]
fn generate_certs_backend_ca_configmap_flags_parse() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL");
let _g2 = EnvVarGuard::remove("POD_NAMESPACE");

let cli = Cli::try_parse_from([
"openshell-gateway",
"generate-certs",
"--namespace",
"openshell",
"--jwt-only",
"--jwt-secret-name",
"openshell-jwt-keys",
"--backend-ca-configmap-name",
"openshell-backend-ca",
"--backend-ca-source-secret",
"openshell-server-tls",
])
.expect("backend CA ConfigMap flags should parse with --jwt-only");

assert!(matches!(
cli.command,
Some(super::Commands::GenerateCerts(_))
));
}

#[test]
fn generate_certs_backend_ca_source_secret_requires_configmap_name() {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL");
let _g2 = EnvVarGuard::remove("POD_NAMESPACE");

let err = Cli::try_parse_from([
"openshell-gateway",
"generate-certs",
"--namespace",
"openshell",
"--jwt-only",
"--jwt-secret-name",
"openshell-jwt-keys",
"--backend-ca-source-secret",
"openshell-server-tls",
])
.expect_err("--backend-ca-source-secret should require --backend-ca-configmap-name");

assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}

#[test]
fn bare_invocation_with_no_db_url_parses_for_runtime_defaults() {
// db_url is Option<String> at the clap level so subcommand parsing
Expand Down
Loading
Loading