mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-04 08:25:55 +08:00
561 lines
19 KiB
Rust
561 lines
19 KiB
Rust
// 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
|
|
//! in the supplied namespace. Used by the Helm pre-install hook. Requires
|
|
//! `--namespace`, `--server-secret-name`, `--client-secret-name`.
|
|
//! - **Local mode** (`--output-dir <DIR>`): write PEMs to a filesystem layout
|
|
//! matching `deploy/rpm/init-pki.sh`. Used by the RPM systemd unit's
|
|
//! `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; 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::Secret;
|
|
use kube::Client;
|
|
use kube::api::{Api, ObjectMeta, PostParams};
|
|
use miette::{IntoDiagnostic, Result, WrapErr};
|
|
use openshell_bootstrap::pki::{PkiBundle, generate_pki};
|
|
use openshell_core::paths::{create_dir_restricted, set_file_owner_only};
|
|
use std::collections::BTreeMap;
|
|
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<PathBuf>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// Name of the server TLS Secret (`kubernetes.io/tls`) to create.
|
|
#[arg(long, required_unless_present = "output_dir")]
|
|
server_secret_name: Option<String>,
|
|
|
|
/// Name of the client TLS Secret (`kubernetes.io/tls`) to create.
|
|
#[arg(long, required_unless_present = "output_dir")]
|
|
client_secret_name: Option<String>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// Print the generated PEM materials to stdout instead of writing them.
|
|
/// For local debugging.
|
|
#[arg(long)]
|
|
dry_run: 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
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────── Kubernetes mode ───────────────────────────
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum K8sAction {
|
|
SkipExists,
|
|
PartialState,
|
|
Create,
|
|
}
|
|
|
|
fn decide_k8s(server_exists: bool, client_exists: bool) -> K8sAction {
|
|
match (server_exists, client_exists) {
|
|
(true, true) => K8sAction::SkipExists,
|
|
(false, false) => K8sAction::Create,
|
|
_ => 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 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 client = Client::try_default()
|
|
.await
|
|
.into_diagnostic()
|
|
.wrap_err("failed to construct in-cluster Kubernetes client")?;
|
|
let api: Api<Secret> = Api::namespaced(client, namespace);
|
|
|
|
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();
|
|
|
|
match decide_k8s(server_exists, client_exists) {
|
|
K8sAction::SkipExists => {
|
|
info!(
|
|
namespace = %namespace,
|
|
server = %server_name,
|
|
client = %client_name,
|
|
"PKI secrets already exist, skipping."
|
|
);
|
|
return Ok(());
|
|
}
|
|
K8sAction::PartialState => {
|
|
return Err(miette::miette!(
|
|
"partial PKI state in namespace {namespace}: exactly one of \
|
|
{server_name} / {client_name} exists. Recover with: \
|
|
kubectl delete secret -n {namespace} {server_name} {client_name}",
|
|
));
|
|
}
|
|
K8sAction::Create => {}
|
|
}
|
|
|
|
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}"))?;
|
|
|
|
info!(
|
|
namespace = %namespace,
|
|
server = %server_name,
|
|
client = %client_name,
|
|
"PKI secrets 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()
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────── Local mode ───────────────────────────────
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum LocalAction {
|
|
Skip,
|
|
PartialState,
|
|
Create,
|
|
}
|
|
|
|
/// Layout under `<dir>` matches `deploy/rpm/init-pki.sh`:
|
|
///
|
|
/// ```text
|
|
/// <dir>/ca.crt
|
|
/// <dir>/ca.key
|
|
/// <dir>/server/tls.crt
|
|
/// <dir>/server/tls.key
|
|
/// <dir>/client/tls.crt
|
|
/// <dir>/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,
|
|
}
|
|
|
|
impl LocalPaths {
|
|
fn resolve(dir: &Path) -> Self {
|
|
let server_dir = dir.join("server");
|
|
let client_dir = dir.join("client");
|
|
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,
|
|
}
|
|
}
|
|
|
|
fn all_files(&self) -> [&Path; 6] {
|
|
[
|
|
&self.ca_crt,
|
|
&self.ca_key,
|
|
&self.server_crt,
|
|
&self.server_key,
|
|
&self.client_crt,
|
|
&self.client_key,
|
|
]
|
|
}
|
|
|
|
fn existence_count(&self) -> usize {
|
|
self.all_files().iter().filter(|p| p.exists()).count()
|
|
}
|
|
}
|
|
|
|
fn decide_local(present: usize) -> LocalAction {
|
|
match present {
|
|
6 => LocalAction::Skip,
|
|
0 => LocalAction::Create,
|
|
_ => LocalAction::PartialState,
|
|
}
|
|
}
|
|
|
|
fn run_local(dir: &Path, server_sans: &[String]) -> Result<()> {
|
|
let paths = LocalPaths::resolve(dir);
|
|
|
|
let bundle = match decide_local(paths.existence_count()) {
|
|
LocalAction::Skip => {
|
|
info!(dir = %dir.display(), "PKI files already exist, skipping.");
|
|
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::Create => {
|
|
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 read_local_bundle(paths: &LocalPaths) -> Result<PkiBundle> {
|
|
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)?,
|
|
})
|
|
}
|
|
|
|
fn read_pem(path: &Path) -> Result<String> {
|
|
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<()> {
|
|
// Stage to a sibling tmp dir so individual renames into the final layout
|
|
// are atomic on the same filesystem.
|
|
let temp = sibling_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");
|
|
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)?;
|
|
|
|
// Final destination (might not exist yet on first run).
|
|
create_dir_restricted(dir)?;
|
|
create_dir_restricted(&paths.server_dir)?;
|
|
create_dir_restricted(&paths.client_dir)?;
|
|
|
|
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_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 sibling_temp_dir(dir: &Path) -> PathBuf {
|
|
// Use a sibling so std::fs::rename succeeds (same filesystem).
|
|
let mut name = dir
|
|
.file_name()
|
|
.map(std::ffi::OsStr::to_os_string)
|
|
.unwrap_or_default();
|
|
name.push(".certgen.tmp");
|
|
dir.with_file_name(name)
|
|
}
|
|
|
|
// ────────────────────────────── 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::{
|
|
K8sAction, LocalAction, LocalPaths, decide_k8s, decide_local, read_local_bundle,
|
|
sibling_temp_dir, tls_secret, write_local_bundle,
|
|
};
|
|
use openshell_bootstrap::pki::generate_pki;
|
|
use std::path::Path;
|
|
|
|
// ── Kubernetes-mode decision ──
|
|
|
|
#[test]
|
|
fn decide_k8s_skip_when_both_exist() {
|
|
assert_eq!(decide_k8s(true, true), K8sAction::SkipExists);
|
|
}
|
|
|
|
#[test]
|
|
fn decide_k8s_create_when_neither_exists() {
|
|
assert_eq!(decide_k8s(false, false), K8sAction::Create);
|
|
}
|
|
|
|
#[test]
|
|
fn decide_k8s_partial_when_only_server_exists() {
|
|
assert_eq!(decide_k8s(true, false), K8sAction::PartialState);
|
|
}
|
|
|
|
#[test]
|
|
fn decide_k8s_partial_when_only_client_exists() {
|
|
assert_eq!(decide_k8s(false, true), K8sAction::PartialState);
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
|
|
// ── Local-mode decision ──
|
|
|
|
#[test]
|
|
fn decide_local_skip_when_all_six_present() {
|
|
assert_eq!(decide_local(6), LocalAction::Skip);
|
|
}
|
|
|
|
#[test]
|
|
fn decide_local_create_when_none_present() {
|
|
assert_eq!(decide_local(0), LocalAction::Create);
|
|
}
|
|
|
|
#[test]
|
|
fn decide_local_partial_for_any_count_in_between() {
|
|
for n in 1..=5 {
|
|
assert_eq!(decide_local(n), LocalAction::PartialState, "n = {n}");
|
|
}
|
|
}
|
|
|
|
// ── 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 sibling_temp_dir_is_adjacent_to_target() {
|
|
assert_eq!(
|
|
sibling_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!(
|
|
!sibling_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 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 = sibling_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");
|
|
}
|
|
}
|