// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! `generate-certs` subcommand: bootstrap mTLS PKI for the gateway.
//!
//! Two output modes, dispatched by the presence of `--output-dir`:
//!
//! - **Kubernetes mode** (default): create two `kubernetes.io/tls` Secrets
//! and one sandbox-JWT signing Secret in the supplied namespace. Used by
//! the Helm pre-install hook. Requires `--namespace`,
//! `--server-secret-name`, `--client-secret-name`, and `--jwt-secret-name`.
//! - **Kubernetes JWT-only mode** (`--jwt-only`): create only the
//! sandbox-JWT signing Secret. Used when another controller, such as
//! cert-manager, owns the TLS Secrets.
//! - **Local mode** (`--output-dir
`): write PEMs to the local package
//! filesystem layout. Used by systemd units' `ExecStartPre`. Also copies
//! client materials to
//! `$XDG_CONFIG_HOME/openshell/gateways/openshell/mtls/` so the local CLI
//! picks them up automatically.
//!
//! Both modes share the same idempotency contract: all targets present →
//! skip; legacy TLS-only state → add the JWT signing material; partial
//! state → error with a recovery hint; nothing present → generate and write.
use clap::Args;
use k8s_openapi::ByteString;
use k8s_openapi::api::core::v1::{ConfigMap, Secret};
use kube::Client;
use kube::api::{Api, ObjectMeta, PostParams};
use miette::{IntoDiagnostic, Result, WrapErr};
use openshell_bootstrap::pki::{DEFAULT_SERVER_SANS, PkiBundle, generate_pki};
use openshell_core::paths::{create_dir_restricted, set_file_owner_only};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::path::{Path, PathBuf};
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
#[derive(Args, Debug)]
pub struct CertgenArgs {
/// Write PEMs to a filesystem directory instead of Kubernetes Secrets.
/// When set, the kube-related flags are not required.
#[arg(long, value_name = "DIR")]
output_dir: Option,
/// Kubernetes namespace to create Secrets in.
/// Default comes from `POD_NAMESPACE`, which the Helm hook injects via
/// the downward API.
#[arg(long, env = "POD_NAMESPACE", required_unless_present = "output_dir")]
namespace: Option,
/// Name of the server TLS Secret (`kubernetes.io/tls`) to create.
#[arg(long, required_unless_present_any = ["output_dir", "jwt_only"])]
server_secret_name: Option,
/// Name of the client TLS Secret (`kubernetes.io/tls`) to create.
#[arg(long, required_unless_present_any = ["output_dir", "jwt_only"])]
client_secret_name: Option,
/// Name of the sandbox-JWT signing-key Secret (`Opaque`) to create.
/// Holds `signing.pem`, `public.pem`, and `kid` keys. Mounted on the
/// gateway pod (only) so it can mint and validate per-sandbox JWTs.
#[arg(long, required_unless_present = "output_dir")]
jwt_secret_name: Option,
/// Create only the sandbox-JWT signing-key Secret in Kubernetes mode.
/// This is used when another controller owns TLS Secret provisioning.
#[arg(long, conflicts_with = "output_dir")]
jwt_only: bool,
/// Extra Subject Alternative Name for the server certificate. Repeatable.
/// Auto-detected as an IP address or DNS name.
#[arg(long = "server-san", value_name = "SAN")]
server_sans: Vec,
/// Print the generated PEM materials to stdout instead of writing them.
/// 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,
/// 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,
/// 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<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
if args.dry_run {
let bundle = generate_pki(&args.server_sans)?;
print_bundle(&bundle);
return Ok(());
}
if let Some(dir) = args.output_dir.as_deref() {
run_local(dir, &args.server_sans)
} else {
let bundle = generate_pki(&args.server_sans)?;
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(())
}
}
// ─────────────────────────── Kubernetes mode ───────────────────────────
#[derive(Debug, PartialEq, Eq)]
enum K8sAction {
SkipExists,
CreateJwtOnly,
PartialState,
CreateAll,
}
fn decide_k8s(server_exists: bool, client_exists: bool, jwt_exists: bool) -> K8sAction {
match (server_exists, client_exists, jwt_exists) {
(true, true, true) => K8sAction::SkipExists,
(true, true, false) => K8sAction::CreateJwtOnly,
(false, false, false) => K8sAction::CreateAll,
_ => K8sAction::PartialState,
}
}
async fn run_kubernetes(args: &CertgenArgs, bundle: &PkiBundle) -> 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 in-cluster Kubernetes client")?;
let api: Api = Api::namespaced(client, namespace);
if args.jwt_only {
let jwt_name = args
.jwt_secret_name
.as_deref()
.ok_or_else(|| miette::miette!("--jwt-secret-name is required"))?;
let jwt_exists = api
.get_opt(jwt_name)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to read secret {jwt_name}"))?
.is_some();
if jwt_exists {
info!(
namespace = %namespace,
jwt = %jwt_name,
"JWT signing secret already exists, skipping."
);
return Ok(());
}
let jwt_secret = jwt_signing_secret(
jwt_name,
&bundle.jwt_signing_key_pem,
&bundle.jwt_public_key_pem,
&bundle.jwt_key_id,
);
api.create(&PostParams::default(), &jwt_secret)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to create secret {jwt_name}"))?;
info!(
namespace = %namespace,
jwt = %jwt_name,
"JWT signing secret created."
);
return Ok(());
}
let server_name = args
.server_secret_name
.as_deref()
.ok_or_else(|| miette::miette!("--server-secret-name is required"))?;
let client_name = args
.client_secret_name
.as_deref()
.ok_or_else(|| miette::miette!("--client-secret-name is required"))?;
let server_exists = api
.get_opt(server_name)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to read secret {server_name}"))?
.is_some();
let client_exists = api
.get_opt(client_name)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to read secret {client_name}"))?
.is_some();
let jwt_name = args
.jwt_secret_name
.as_deref()
.ok_or_else(|| miette::miette!("--jwt-secret-name is required"))?;
let jwt_exists = api
.get_opt(jwt_name)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to read secret {jwt_name}"))?
.is_some();
match decide_k8s(server_exists, client_exists, jwt_exists) {
K8sAction::SkipExists => {
info!(
namespace = %namespace,
server = %server_name,
client = %client_name,
jwt = %jwt_name,
"PKI secrets already exist, skipping."
);
return Ok(());
}
K8sAction::PartialState => {
return Err(miette::miette!(
"partial PKI state in namespace {namespace}: only some of \
{server_name} / {client_name} / {jwt_name} exist. Recover with: \
kubectl delete secret -n {namespace} {server_name} {client_name} {jwt_name}",
));
}
K8sAction::CreateJwtOnly => {
let jwt_secret = jwt_signing_secret(
jwt_name,
&bundle.jwt_signing_key_pem,
&bundle.jwt_public_key_pem,
&bundle.jwt_key_id,
);
api.create(&PostParams::default(), &jwt_secret)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to create secret {jwt_name}"))?;
info!(
namespace = %namespace,
jwt = %jwt_name,
"JWT signing secret created for existing TLS install."
);
return Ok(());
}
K8sAction::CreateAll => {}
}
create_tls_secrets(&api, server_name, client_name, bundle).await?;
let jwt_secret = jwt_signing_secret(
jwt_name,
&bundle.jwt_signing_key_pem,
&bundle.jwt_public_key_pem,
&bundle.jwt_key_id,
);
api.create(&PostParams::default(), &jwt_secret)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to create secret {jwt_name}"))?;
info!(
namespace = %namespace,
server = %server_name,
client = %client_name,
jwt = %jwt_name,
"PKI secrets created."
);
Ok(())
}
async fn create_tls_secrets(
api: &Api,
server_name: &str,
client_name: &str,
bundle: &PkiBundle,
) -> Result<()> {
let server_secret = tls_secret(
server_name,
&bundle.server_cert_pem,
&bundle.server_key_pem,
&bundle.ca_cert_pem,
);
let client_secret = tls_secret(
client_name,
&bundle.client_cert_pem,
&bundle.client_key_pem,
&bundle.ca_cert_pem,
);
api.create(&PostParams::default(), &server_secret)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to create secret {server_name}"))?;
api.create(&PostParams::default(), &client_secret)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to create secret {client_name}"))?;
Ok(())
}
fn extract_ca_from_secret(secret: &Secret, name: &str) -> Result {
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 = Api::namespaced(client.clone(), namespace);
let cm_api: Api = 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(
"tls.crt".to_string(),
ByteString(crt_pem.as_bytes().to_vec()),
);
data.insert(
"tls.key".to_string(),
ByteString(key_pem.as_bytes().to_vec()),
);
data.insert("ca.crt".to_string(), ByteString(ca_pem.as_bytes().to_vec()));
Secret {
metadata: ObjectMeta {
name: Some(name.to_string()),
..Default::default()
},
type_: Some("kubernetes.io/tls".to_string()),
data: Some(data),
..Default::default()
}
}
/// Build an `Opaque` Secret carrying the gateway-minted sandbox JWT
/// signing material. Mounted only on the gateway pod — sandbox pods
/// receive a per-pod gateway-signed token, never the signing key itself.
fn jwt_signing_secret(name: &str, signing_pem: &str, public_pem: &str, kid: &str) -> Secret {
let mut data = BTreeMap::new();
data.insert(
"signing.pem".to_string(),
ByteString(signing_pem.as_bytes().to_vec()),
);
data.insert(
"public.pem".to_string(),
ByteString(public_pem.as_bytes().to_vec()),
);
data.insert("kid".to_string(), ByteString(kid.as_bytes().to_vec()));
Secret {
metadata: ObjectMeta {
name: Some(name.to_string()),
..Default::default()
},
type_: Some("Opaque".to_string()),
data: Some(data),
..Default::default()
}
}
// ─────────────────────────────── Local mode ───────────────────────────────
#[derive(Debug, PartialEq, Eq)]
enum LocalAction {
Skip,
CreateJwtOnly,
PartialState,
CreateAll,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum CertSan {
Dns(String),
Ip(IpAddr),
}
impl fmt::Display for CertSan {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Dns(name) => write!(f, "{name}"),
Self::Ip(addr) => write!(f, "{addr}"),
}
}
}
/// Layout under ``:
///
/// ```text
/// /ca.crt
/// /ca.key
/// /server/tls.crt
/// /server/tls.key
/// /client/tls.crt
/// /client/tls.key
/// ```
struct LocalPaths {
ca_crt: PathBuf,
ca_key: PathBuf,
server_dir: PathBuf,
server_crt: PathBuf,
server_key: PathBuf,
client_dir: PathBuf,
client_crt: PathBuf,
client_key: PathBuf,
jwt_dir: PathBuf,
jwt_signing: PathBuf,
jwt_public: PathBuf,
jwt_kid: PathBuf,
}
impl LocalPaths {
fn resolve(dir: &Path) -> Self {
let server_dir = dir.join("server");
let client_dir = dir.join("client");
let jwt_dir = dir.join("jwt");
Self {
ca_crt: dir.join("ca.crt"),
ca_key: dir.join("ca.key"),
server_crt: server_dir.join("tls.crt"),
server_key: server_dir.join("tls.key"),
server_dir,
client_crt: client_dir.join("tls.crt"),
client_key: client_dir.join("tls.key"),
client_dir,
jwt_signing: jwt_dir.join("signing.pem"),
jwt_public: jwt_dir.join("public.pem"),
jwt_kid: jwt_dir.join("kid"),
jwt_dir,
}
}
fn tls_files(&self) -> [&Path; 6] {
[
&self.ca_crt,
&self.ca_key,
&self.server_crt,
&self.server_key,
&self.client_crt,
&self.client_key,
]
}
fn jwt_files(&self) -> [&Path; 3] {
[&self.jwt_signing, &self.jwt_public, &self.jwt_kid]
}
#[cfg(test)]
fn all_files(&self) -> [&Path; 9] {
[
&self.ca_crt,
&self.ca_key,
&self.server_crt,
&self.server_key,
&self.client_crt,
&self.client_key,
&self.jwt_signing,
&self.jwt_public,
&self.jwt_kid,
]
}
fn tls_existence_count(&self) -> usize {
self.tls_files().iter().filter(|p| p.exists()).count()
}
fn jwt_existence_count(&self) -> usize {
self.jwt_files().iter().filter(|p| p.exists()).count()
}
}
fn decide_local(tls_present: usize, jwt_present: usize) -> LocalAction {
match (tls_present, jwt_present) {
(6, 3) => LocalAction::Skip,
(6, 0) => LocalAction::CreateJwtOnly,
(0, 0) => LocalAction::CreateAll,
_ => LocalAction::PartialState,
}
}
fn run_local(dir: &Path, server_sans: &[String]) -> Result<()> {
let paths = LocalPaths::resolve(dir);
let bundle = match decide_local(paths.tls_existence_count(), paths.jwt_existence_count()) {
LocalAction::Skip => {
let missing_sans = missing_required_server_sans(&paths, server_sans)?;
if missing_sans.is_empty() {
info!(dir = %dir.display(), "PKI files already exist, skipping.");
} else {
let bundle = generate_pki(server_sans)?;
write_local_tls_bundle(&bundle, &paths)?;
info!(
dir = %dir.display(),
missing_sans = %format_cert_sans(&missing_sans),
"server TLS certificate refreshed for current SAN set.",
);
}
read_local_bundle(&paths)?
}
LocalAction::CreateJwtOnly => {
let bundle = generate_pki(server_sans)?;
let missing_sans = missing_required_server_sans(&paths, server_sans)?;
if missing_sans.is_empty() {
write_local_jwt_bundle(&bundle, &paths)?;
info!(dir = %dir.display(), "JWT signing files created for existing TLS install.");
} else {
write_local_bundle(dir, &bundle, &paths)?;
info!(
dir = %dir.display(),
missing_sans = %format_cert_sans(&missing_sans),
"PKI files refreshed for current SAN set and JWT signing material.",
);
}
read_local_bundle(&paths)?
}
LocalAction::PartialState => {
return Err(miette::miette!(
"partial PKI state in {dir}: some files exist but not all. \
Recover with: rm -rf {dir} (the gateway will regenerate on next start)",
dir = dir.display(),
));
}
LocalAction::CreateAll => {
let bundle = generate_pki(server_sans)?;
write_local_bundle(dir, &bundle, &paths)?;
info!(dir = %dir.display(), "PKI files created.");
bundle
}
};
// Always make sure the CLI auto-discovery copy is in place. This
// self-heals the case where the operator wiped ~/.config/openshell but
// left the gateway state directory intact.
if let Err(e) = openshell_bootstrap::mtls::store_pki_bundle("openshell", &bundle) {
warn!(error = %e, "failed to copy client mTLS materials for CLI auto-discovery");
}
Ok(())
}
fn required_server_sans(server_sans: &[String]) -> BTreeSet {
DEFAULT_SERVER_SANS
.iter()
.copied()
.chain(server_sans.iter().map(String::as_str))
.filter_map(|san| {
san.parse::()
.map(CertSan::Ip)
.ok()
.or_else(|| san.is_ascii().then(|| CertSan::Dns(san.to_string())))
})
.collect()
}
fn missing_required_server_sans(
paths: &LocalPaths,
server_sans: &[String],
) -> Result> {
let required = required_server_sans(server_sans);
let actual = server_cert_sans(&paths.server_crt)?;
Ok(required.difference(&actual).cloned().collect())
}
fn server_cert_sans(path: &Path) -> Result> {
use x509_parser::pem::parse_x509_pem;
use x509_parser::prelude::{FromDer, GeneralName, X509Certificate};
let pem = std::fs::read(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", path.display()))?;
let (_, pem) = parse_x509_pem(&pem).map_err(|e| {
miette::miette!(
"failed to parse server certificate PEM {}: {e:?}",
path.display()
)
})?;
let (_, cert) = X509Certificate::from_der(&pem.contents).map_err(|e| {
miette::miette!(
"failed to parse server certificate {}: {e:?}",
path.display()
)
})?;
let Some(ext) = cert.subject_alternative_name().map_err(|e| {
miette::miette!(
"failed to read server certificate SANs {}: {e:?}",
path.display()
)
})?
else {
return Ok(BTreeSet::new());
};
let mut sans = BTreeSet::new();
for name in &ext.value.general_names {
match name {
GeneralName::DNSName(name) => {
sans.insert(CertSan::Dns((*name).to_string()));
}
GeneralName::IPAddress(raw) => match raw.len() {
4 => {
sans.insert(CertSan::Ip(IpAddr::V4(Ipv4Addr::new(
raw[0], raw[1], raw[2], raw[3],
))));
}
16 => {
let octets: [u8; 16] = (*raw).try_into().expect("checked IPv6 SAN length");
sans.insert(CertSan::Ip(IpAddr::V6(Ipv6Addr::from(octets))));
}
_ => {}
},
_ => {}
}
}
Ok(sans)
}
fn format_cert_sans(sans: &[CertSan]) -> String {
sans.iter()
.map(ToString::to_string)
.collect::>()
.join(", ")
}
fn read_local_bundle(paths: &LocalPaths) -> Result {
Ok(PkiBundle {
ca_cert_pem: read_pem(&paths.ca_crt)?,
ca_key_pem: read_pem(&paths.ca_key)?,
server_cert_pem: read_pem(&paths.server_crt)?,
server_key_pem: read_pem(&paths.server_key)?,
client_cert_pem: read_pem(&paths.client_crt)?,
client_key_pem: read_pem(&paths.client_key)?,
jwt_signing_key_pem: read_pem(&paths.jwt_signing)?,
jwt_public_key_pem: read_pem(&paths.jwt_public)?,
jwt_key_id: read_pem(&paths.jwt_kid)?.trim().to_string(),
})
}
fn read_pem(path: &Path) -> Result {
std::fs::read_to_string(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", path.display()))
}
fn write_local_bundle(dir: &Path, bundle: &PkiBundle, paths: &LocalPaths) -> Result<()> {
// Ensure output dir exists before staging inside it.
create_dir_restricted(dir)?;
// Stage inside the output dir so individual renames are atomic on the same filesystem.
let temp = staging_temp_dir(dir);
if temp.exists() {
std::fs::remove_dir_all(&temp)
.into_diagnostic()
.wrap_err_with(|| format!("failed to remove stale {}", temp.display()))?;
}
let temp_server = temp.join("server");
let temp_client = temp.join("client");
let temp_jwt = temp.join("jwt");
create_dir_restricted(&temp)?;
create_dir_restricted(&temp_server)?;
create_dir_restricted(&temp_client)?;
create_dir_restricted(&temp_jwt)?;
write_pem(&temp.join("ca.crt"), &bundle.ca_cert_pem, false)?;
write_pem(&temp.join("ca.key"), &bundle.ca_key_pem, true)?;
write_pem(&temp_server.join("tls.crt"), &bundle.server_cert_pem, false)?;
write_pem(&temp_server.join("tls.key"), &bundle.server_key_pem, true)?;
write_pem(&temp_client.join("tls.crt"), &bundle.client_cert_pem, false)?;
write_pem(&temp_client.join("tls.key"), &bundle.client_key_pem, true)?;
write_pem(
&temp_jwt.join("signing.pem"),
&bundle.jwt_signing_key_pem,
true,
)?;
write_pem(
&temp_jwt.join("public.pem"),
&bundle.jwt_public_key_pem,
false,
)?;
write_pem(&temp_jwt.join("kid"), &bundle.jwt_key_id, false)?;
// Final destination subdirs (might not exist yet on first run).
create_dir_restricted(&paths.server_dir)?;
create_dir_restricted(&paths.client_dir)?;
create_dir_restricted(&paths.jwt_dir)?;
let renames: [(PathBuf, &Path); 9] = [
(temp.join("ca.crt"), paths.ca_crt.as_path()),
(temp.join("ca.key"), paths.ca_key.as_path()),
(temp_server.join("tls.crt"), paths.server_crt.as_path()),
(temp_server.join("tls.key"), paths.server_key.as_path()),
(temp_client.join("tls.crt"), paths.client_crt.as_path()),
(temp_client.join("tls.key"), paths.client_key.as_path()),
(temp_jwt.join("signing.pem"), paths.jwt_signing.as_path()),
(temp_jwt.join("public.pem"), paths.jwt_public.as_path()),
(temp_jwt.join("kid"), paths.jwt_kid.as_path()),
];
for (from, to) in &renames {
std::fs::rename(from, to)
.into_diagnostic()
.wrap_err_with(|| format!("failed to move {} -> {}", from.display(), to.display()))?;
}
let _ = std::fs::remove_dir_all(&temp);
Ok(())
}
fn write_local_tls_bundle(bundle: &PkiBundle, paths: &LocalPaths) -> Result<()> {
create_dir_restricted(&paths.server_dir)?;
create_dir_restricted(&paths.client_dir)?;
let temp = staging_temp_dir(&paths.server_dir);
if temp.exists() {
std::fs::remove_dir_all(&temp)
.into_diagnostic()
.wrap_err_with(|| format!("failed to remove stale {}", temp.display()))?;
}
let temp_server = temp.join("server");
let temp_client = temp.join("client");
create_dir_restricted(&temp)?;
create_dir_restricted(&temp_server)?;
create_dir_restricted(&temp_client)?;
write_pem(&temp.join("ca.crt"), &bundle.ca_cert_pem, false)?;
write_pem(&temp.join("ca.key"), &bundle.ca_key_pem, true)?;
write_pem(&temp_server.join("tls.crt"), &bundle.server_cert_pem, false)?;
write_pem(&temp_server.join("tls.key"), &bundle.server_key_pem, true)?;
write_pem(&temp_client.join("tls.crt"), &bundle.client_cert_pem, false)?;
write_pem(&temp_client.join("tls.key"), &bundle.client_key_pem, true)?;
let renames: [(PathBuf, &Path); 6] = [
(temp.join("ca.crt"), paths.ca_crt.as_path()),
(temp.join("ca.key"), paths.ca_key.as_path()),
(temp_server.join("tls.crt"), paths.server_crt.as_path()),
(temp_server.join("tls.key"), paths.server_key.as_path()),
(temp_client.join("tls.crt"), paths.client_crt.as_path()),
(temp_client.join("tls.key"), paths.client_key.as_path()),
];
for (from, to) in &renames {
std::fs::rename(from, to)
.into_diagnostic()
.wrap_err_with(|| format!("failed to move {} -> {}", from.display(), to.display()))?;
}
let _ = std::fs::remove_dir_all(&temp);
Ok(())
}
fn write_local_jwt_bundle(bundle: &PkiBundle, paths: &LocalPaths) -> Result<()> {
create_dir_restricted(&paths.jwt_dir)?;
let temp = staging_temp_dir(&paths.jwt_dir);
if temp.exists() {
std::fs::remove_dir_all(&temp)
.into_diagnostic()
.wrap_err_with(|| format!("failed to remove stale {}", temp.display()))?;
}
create_dir_restricted(&temp)?;
write_pem(&temp.join("signing.pem"), &bundle.jwt_signing_key_pem, true)?;
write_pem(&temp.join("public.pem"), &bundle.jwt_public_key_pem, false)?;
write_pem(&temp.join("kid"), &bundle.jwt_key_id, false)?;
let renames: [(PathBuf, &Path); 3] = [
(temp.join("signing.pem"), paths.jwt_signing.as_path()),
(temp.join("public.pem"), paths.jwt_public.as_path()),
(temp.join("kid"), paths.jwt_kid.as_path()),
];
for (from, to) in &renames {
std::fs::rename(from, to)
.into_diagnostic()
.wrap_err_with(|| format!("failed to move {} -> {}", from.display(), to.display()))?;
}
let _ = std::fs::remove_dir_all(&temp);
Ok(())
}
fn write_pem(path: &Path, contents: &str, owner_only: bool) -> Result<()> {
std::fs::write(path, contents)
.into_diagnostic()
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
if owner_only {
set_file_owner_only(path)?;
}
Ok(())
}
fn staging_temp_dir(dir: &Path) -> PathBuf {
// Place temp inside dir so rename stays on the same filesystem as the destination.
dir.join(".certgen.tmp")
}
// ────────────────────────────── Shared utility ─────────────────────────────
fn print_bundle(bundle: &PkiBundle) {
println!("# CA certificate\n{}", bundle.ca_cert_pem);
println!("# Server certificate\n{}", bundle.server_cert_pem);
println!("# Server key\n{}", bundle.server_key_pem);
println!("# Client certificate\n{}", bundle.client_cert_pem);
println!("# Client key\n{}", bundle.client_key_pem);
}
#[cfg(test)]
mod tests {
use super::{
CertSan, K8sAction, LocalAction, LocalPaths, decide_k8s, decide_local, jwt_signing_secret,
missing_required_server_sans, read_local_bundle, staging_temp_dir, tls_secret,
write_local_bundle, write_local_jwt_bundle, write_local_tls_bundle,
};
use openshell_bootstrap::pki::generate_pki;
use std::path::Path;
// ── Kubernetes-mode decision ──
#[test]
fn decide_k8s_skip_when_all_three_exist() {
assert_eq!(decide_k8s(true, true, true), K8sAction::SkipExists);
}
#[test]
fn decide_k8s_create_when_none_exist() {
assert_eq!(decide_k8s(false, false, false), K8sAction::CreateAll);
}
#[test]
fn decide_k8s_creates_jwt_only_for_existing_tls() {
assert_eq!(decide_k8s(true, true, false), K8sAction::CreateJwtOnly);
}
#[test]
fn decide_k8s_partial_for_any_mixed_state() {
let mixes = [
(true, false, false),
(false, true, false),
(false, false, true),
(true, false, true),
(false, true, true),
];
for (s, c, j) in mixes {
assert_eq!(
decide_k8s(s, c, j),
K8sAction::PartialState,
"({s},{c},{j})"
);
}
}
#[test]
fn tls_secret_has_kubernetes_io_tls_type_and_three_keys() {
let s = tls_secret("foo", "CRT-PEM", "KEY-PEM", "CA-PEM");
assert_eq!(s.metadata.name.as_deref(), Some("foo"));
assert_eq!(s.type_.as_deref(), Some("kubernetes.io/tls"));
let data = s.data.expect("data set");
assert_eq!(data.len(), 3);
assert_eq!(data["tls.crt"].0, b"CRT-PEM");
assert_eq!(data["tls.key"].0, b"KEY-PEM");
assert_eq!(data["ca.crt"].0, b"CA-PEM");
}
#[test]
fn jwt_signing_secret_has_opaque_type_and_three_keys() {
let s = jwt_signing_secret("jwt", "SIGN", "PUB", "kid-1");
assert_eq!(s.metadata.name.as_deref(), Some("jwt"));
assert_eq!(s.type_.as_deref(), Some("Opaque"));
let data = s.data.expect("data set");
assert_eq!(data.len(), 3);
assert_eq!(data["signing.pem"].0, b"SIGN");
assert_eq!(data["public.pem"].0, b"PUB");
assert_eq!(data["kid"].0, b"kid-1");
}
// ── Local-mode decision ──
#[test]
fn decide_local_skip_when_all_nine_present() {
assert_eq!(decide_local(6, 3), LocalAction::Skip);
}
#[test]
fn decide_local_create_when_none_present() {
assert_eq!(decide_local(0, 0), LocalAction::CreateAll);
}
#[test]
fn decide_local_creates_jwt_only_for_existing_tls() {
assert_eq!(decide_local(6, 0), LocalAction::CreateJwtOnly);
}
#[test]
fn decide_local_partial_for_incomplete_tls_or_jwt_sets() {
for tls in 0..=6 {
for jwt in 0..=3 {
if matches!((tls, jwt), (6, 3 | 0) | (0, 0)) {
continue;
}
assert_eq!(
decide_local(tls, jwt),
LocalAction::PartialState,
"tls={tls} jwt={jwt}"
);
}
}
}
// ── Local-mode layout & writes ──
#[test]
fn local_paths_resolve_matches_init_pki_layout() {
let p = LocalPaths::resolve(Path::new("/tmp/openshell/tls"));
assert_eq!(p.ca_crt, Path::new("/tmp/openshell/tls/ca.crt"));
assert_eq!(p.ca_key, Path::new("/tmp/openshell/tls/ca.key"));
assert_eq!(p.server_crt, Path::new("/tmp/openshell/tls/server/tls.crt"));
assert_eq!(p.server_key, Path::new("/tmp/openshell/tls/server/tls.key"));
assert_eq!(p.client_crt, Path::new("/tmp/openshell/tls/client/tls.crt"));
assert_eq!(p.client_key, Path::new("/tmp/openshell/tls/client/tls.key"));
}
#[test]
fn staging_temp_dir_is_inside_target() {
assert_eq!(
staging_temp_dir(Path::new("/var/lib/openshell/tls")),
Path::new("/var/lib/openshell/tls/.certgen.tmp")
);
}
#[test]
fn write_local_bundle_writes_six_files_and_removes_temp() {
let parent = tempfile::tempdir().expect("tempdir");
let dir = parent.path().join("tls");
let bundle = generate_pki(&[]).expect("generate_pki");
let paths = LocalPaths::resolve(&dir);
write_local_bundle(&dir, &bundle, &paths).expect("write_local_bundle");
for f in paths.all_files() {
assert!(f.is_file(), "missing {}", f.display());
}
assert!(
!staging_temp_dir(&dir).exists(),
"temp dir should be cleaned up"
);
// Spot-check contents.
let ca = std::fs::read_to_string(&paths.ca_crt).unwrap();
assert!(ca.contains("BEGIN CERTIFICATE"));
let server_key = std::fs::read_to_string(&paths.server_key).unwrap();
assert!(server_key.contains("BEGIN PRIVATE KEY"));
}
#[test]
fn write_local_jwt_bundle_preserves_existing_tls_files() {
let parent = tempfile::tempdir().expect("tempdir");
let dir = parent.path().join("tls");
let old_bundle = generate_pki(&[]).expect("generate_pki");
let new_bundle = generate_pki(&[]).expect("generate_pki");
let paths = LocalPaths::resolve(&dir);
write_local_bundle(&dir, &old_bundle, &paths).expect("write_local_bundle");
std::fs::remove_dir_all(&paths.jwt_dir).expect("remove jwt dir");
write_local_jwt_bundle(&new_bundle, &paths).expect("write_local_jwt_bundle");
let read = read_local_bundle(&paths).expect("read_local_bundle");
assert_eq!(read.ca_cert_pem, old_bundle.ca_cert_pem);
assert_eq!(read.server_cert_pem, old_bundle.server_cert_pem);
assert_eq!(read.client_cert_pem, old_bundle.client_cert_pem);
assert_eq!(read.jwt_key_id, new_bundle.jwt_key_id);
assert_eq!(read.jwt_public_key_pem, new_bundle.jwt_public_key_pem);
}
#[test]
fn write_local_tls_bundle_preserves_existing_jwt_files() {
let parent = tempfile::tempdir().expect("tempdir");
let dir = parent.path().join("tls");
let old_bundle = generate_pki(&[]).expect("generate_pki");
let new_bundle = generate_pki(&["extra.example.test".to_string()]).expect("generate_pki");
let paths = LocalPaths::resolve(&dir);
write_local_bundle(&dir, &old_bundle, &paths).expect("write_local_bundle");
write_local_tls_bundle(&new_bundle, &paths).expect("write_local_tls_bundle");
let read = read_local_bundle(&paths).expect("read_local_bundle");
assert_eq!(read.ca_cert_pem, new_bundle.ca_cert_pem);
assert_eq!(read.server_cert_pem, new_bundle.server_cert_pem);
assert_eq!(read.client_cert_pem, new_bundle.client_cert_pem);
assert_eq!(read.jwt_key_id, old_bundle.jwt_key_id);
assert_eq!(read.jwt_public_key_pem, old_bundle.jwt_public_key_pem);
}
#[test]
fn missing_required_server_sans_detects_new_required_name() {
let parent = tempfile::tempdir().expect("tempdir");
let dir = parent.path().join("tls");
let bundle = generate_pki(&[]).expect("generate_pki");
let paths = LocalPaths::resolve(&dir);
write_local_bundle(&dir, &bundle, &paths).expect("write_local_bundle");
assert!(
missing_required_server_sans(&paths, &[])
.unwrap()
.is_empty()
);
let missing =
missing_required_server_sans(&paths, &["future.example.test".to_string()]).unwrap();
assert_eq!(
missing,
vec![CertSan::Dns("future.example.test".to_string())]
);
}
#[test]
fn read_local_bundle_uses_existing_files() {
let parent = tempfile::tempdir().expect("tempdir");
let dir = parent.path().join("tls");
let bundle = generate_pki(&[]).expect("generate_pki");
let paths = LocalPaths::resolve(&dir);
write_local_bundle(&dir, &bundle, &paths).expect("write_local_bundle");
let read = read_local_bundle(&paths).expect("read_local_bundle");
assert_eq!(read.ca_cert_pem, bundle.ca_cert_pem);
assert_eq!(read.client_cert_pem, bundle.client_cert_pem);
assert_eq!(read.client_key_pem, bundle.client_key_pem);
}
#[cfg(unix)]
#[test]
fn write_local_bundle_sets_owner_only_on_keys() {
use std::os::unix::fs::PermissionsExt;
let parent = tempfile::tempdir().expect("tempdir");
let dir = parent.path().join("tls");
let bundle = generate_pki(&[]).expect("generate_pki");
let paths = LocalPaths::resolve(&dir);
write_local_bundle(&dir, &bundle, &paths).expect("write_local_bundle");
for key in [&paths.ca_key, &paths.server_key, &paths.client_key] {
let mode = std::fs::metadata(key).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "key {} has mode {:o}", key.display(), mode);
}
}
#[test]
fn write_local_bundle_recovers_from_stale_temp_dir() {
let parent = tempfile::tempdir().expect("tempdir");
let dir = parent.path().join("tls");
let stale = staging_temp_dir(&dir);
std::fs::create_dir_all(&stale).unwrap();
std::fs::write(stale.join("garbage"), "stale").unwrap();
let bundle = generate_pki(&[]).expect("generate_pki");
let paths = LocalPaths::resolve(&dir);
write_local_bundle(&dir, &bundle, &paths).expect("write_local_bundle");
assert!(paths.ca_crt.is_file());
assert!(!stale.exists(), "stale temp dir should be removed");
}
}