Skip to content

Commit 31cc445

Browse files
committed
feat(server): add generate-certs subcommand and replace alpine PKI hook
Introduce `openshell-gateway generate-certs` modeled on envoyproxy/gateway's certgen pattern. The Helm pre-install/pre-upgrade hook now runs the gateway image instead of an alpine + openssl shell job — one image to mirror in air-gapped environments, one PKI implementation, real test coverage. Reuses `openshell_bootstrap::pki::generate_pki` for CA/server/client cert generation. Idempotency contract preserved: both Secrets exist → skip; one exists → fail with `kubectl delete` recovery hint; neither exists → POST both `kubernetes.io/tls` Secrets. The server CLI gains optional subcommand support: bare `openshell-gateway` still runs the gateway, `generate-certs` runs the new path. `--db-url` moved from clap-required to call-site validated to avoid the clap flatten + required-field landmine.
1 parent 49cc5a0 commit 31cc445

11 files changed

Lines changed: 483 additions & 232 deletions

File tree

.agents/skills/helm-dev-environment/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ mise run helm:skaffold:run
5757
```
5858

5959
Both commands build the `gateway` and `supervisor` images and deploy the OpenShell Helm
60-
chart. The `pkiInitJob` hook runs on first install to generate mTLS secrets. Envoy Gateway opt-in; see the Optional Add-ons section below.
60+
chart. The `pkiInitJob` hook (a pre-install Job that runs `openshell-gateway generate-certs`)
61+
generates mTLS secrets on first install. Envoy Gateway opt-in; see the Optional Add-ons section below.
6162

6263
The gateway Service uses ClusterIP. Access is via Envoy Gateway (port `8080`) or `kubectl port-forward`.
6364

Cargo.lock

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

architecture/gateway.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,24 @@ The same relay pattern backs interactive SSH, command execution, and file sync.
132132
The gateway tracks live sessions in memory and persists session records so
133133
tokens can expire or be revoked.
134134

135+
## PKI Bootstrap
136+
137+
On Kubernetes, the Helm chart runs a pre-install/pre-upgrade hook Job that
138+
invokes `openshell-gateway generate-certs` to create the gateway's server and
139+
client mTLS Secrets. The Job uses the gateway image itself — no separate
140+
cert-generation image — and reuses the same `rcgen`-based CA/leaf logic as the
141+
local `openshell` workflow.
142+
143+
The hook is idempotent: both target Secrets present → skip; exactly one
144+
present → fail with a recovery hint; neither present → generate and create.
145+
This guards mTLS continuity across upgrades while still recovering cleanly if
146+
an operator deletes both Secrets and reinstalls.
147+
148+
Operators who manage PKI externally (cert-manager, an enterprise CA, or
149+
pre-created Secrets) disable the hook via `pkiInitJob.enabled=false`. The chart
150+
also ships a `certManager.*` path that produces equivalent Secrets through
151+
cert-manager `Issuer`/`Certificate` resources.
152+
135153
## Operational Constraints
136154

137155
- Gateway TLS and client certificate distribution are deployment concerns owned

crates/openshell-server/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ name = "openshell-gateway"
1515
path = "src/main.rs"
1616

1717
[dependencies]
18+
openshell-bootstrap = { path = "../openshell-bootstrap" }
1819
openshell-core = { path = "../openshell-core" }
1920
openshell-driver-docker = { path = "../openshell-driver-docker" }
2021
openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes" }
@@ -24,6 +25,10 @@ openshell-policy = { path = "../openshell-policy" }
2425
openshell-providers = { path = "../openshell-providers" }
2526
openshell-router = { path = "../openshell-router" }
2627

28+
# Kubernetes client (used by the `generate-certs` subcommand)
29+
kube = { workspace = true }
30+
k8s-openapi = { workspace = true }
31+
2732
# Async runtime
2833
tokio = { workspace = true }
2934

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! `generate-certs` subcommand: bootstrap mTLS PKI as Kubernetes Secrets.
5+
//!
6+
//! Invoked by the Helm pre-install/pre-upgrade hook to create the gateway's
7+
//! server and client TLS Secrets. Replaces the previous alpine + openssl
8+
//! shell job by reusing [`openshell_bootstrap::pki::generate_pki`].
9+
//!
10+
//! Idempotency:
11+
//! - Both target Secrets exist → log and exit 0.
12+
//! - Exactly one exists → error with a `kubectl delete` recovery hint.
13+
//! - Neither exists → generate PKI and POST both Secrets.
14+
15+
use clap::Args;
16+
use k8s_openapi::ByteString;
17+
use k8s_openapi::api::core::v1::Secret;
18+
use kube::Client;
19+
use kube::api::{Api, ObjectMeta, PostParams};
20+
use miette::{IntoDiagnostic, Result, WrapErr};
21+
use openshell_bootstrap::pki::{PkiBundle, generate_pki};
22+
use std::collections::BTreeMap;
23+
use tracing::info;
24+
use tracing_subscriber::EnvFilter;
25+
26+
#[derive(Args, Debug)]
27+
pub struct CertgenArgs {
28+
/// Kubernetes namespace to create Secrets in.
29+
/// Default comes from `POD_NAMESPACE`, which the Helm hook injects via
30+
/// the downward API.
31+
#[arg(long, env = "POD_NAMESPACE")]
32+
namespace: String,
33+
34+
/// Name of the server TLS Secret (`kubernetes.io/tls`) to create.
35+
#[arg(long)]
36+
server_secret_name: String,
37+
38+
/// Name of the client TLS Secret (`kubernetes.io/tls`) to create.
39+
#[arg(long)]
40+
client_secret_name: String,
41+
42+
/// Extra Subject Alternative Name for the server certificate. Repeatable.
43+
/// Auto-detected as an IP address or DNS name.
44+
#[arg(long = "server-san", value_name = "SAN")]
45+
server_sans: Vec<String>,
46+
47+
/// Print the generated PEM materials to stdout instead of creating
48+
/// Kubernetes Secrets. For local debugging.
49+
#[arg(long)]
50+
dry_run: bool,
51+
}
52+
53+
#[derive(Debug, PartialEq, Eq)]
54+
enum Action {
55+
SkipExists,
56+
PartialState,
57+
Create,
58+
}
59+
60+
fn decide(server_exists: bool, client_exists: bool) -> Action {
61+
match (server_exists, client_exists) {
62+
(true, true) => Action::SkipExists,
63+
(false, false) => Action::Create,
64+
_ => Action::PartialState,
65+
}
66+
}
67+
68+
pub async fn run(args: CertgenArgs) -> Result<()> {
69+
tracing_subscriber::fmt()
70+
.with_env_filter(
71+
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
72+
)
73+
.init();
74+
75+
let bundle = generate_pki(&args.server_sans)?;
76+
77+
if args.dry_run {
78+
print_bundle(&bundle);
79+
return Ok(());
80+
}
81+
82+
let client = Client::try_default()
83+
.await
84+
.into_diagnostic()
85+
.wrap_err("failed to construct in-cluster Kubernetes client")?;
86+
let api: Api<Secret> = Api::namespaced(client, &args.namespace);
87+
88+
let server_exists = api
89+
.get_opt(&args.server_secret_name)
90+
.await
91+
.into_diagnostic()
92+
.wrap_err_with(|| format!("failed to read secret {}", args.server_secret_name))?
93+
.is_some();
94+
let client_exists = api
95+
.get_opt(&args.client_secret_name)
96+
.await
97+
.into_diagnostic()
98+
.wrap_err_with(|| format!("failed to read secret {}", args.client_secret_name))?
99+
.is_some();
100+
101+
match decide(server_exists, client_exists) {
102+
Action::SkipExists => {
103+
info!(
104+
namespace = %args.namespace,
105+
server = %args.server_secret_name,
106+
client = %args.client_secret_name,
107+
"PKI secrets already exist, skipping."
108+
);
109+
return Ok(());
110+
}
111+
Action::PartialState => {
112+
return Err(miette::miette!(
113+
"partial PKI state in namespace {ns}: exactly one of {server} / {client} \
114+
exists. Recover with: kubectl delete secret -n {ns} {server} {client}",
115+
ns = args.namespace,
116+
server = args.server_secret_name,
117+
client = args.client_secret_name,
118+
));
119+
}
120+
Action::Create => {}
121+
}
122+
123+
let server_secret = tls_secret(
124+
&args.server_secret_name,
125+
&bundle.server_cert_pem,
126+
&bundle.server_key_pem,
127+
&bundle.ca_cert_pem,
128+
);
129+
let client_secret = tls_secret(
130+
&args.client_secret_name,
131+
&bundle.client_cert_pem,
132+
&bundle.client_key_pem,
133+
&bundle.ca_cert_pem,
134+
);
135+
136+
api.create(&PostParams::default(), &server_secret)
137+
.await
138+
.into_diagnostic()
139+
.wrap_err_with(|| format!("failed to create secret {}", args.server_secret_name))?;
140+
api.create(&PostParams::default(), &client_secret)
141+
.await
142+
.into_diagnostic()
143+
.wrap_err_with(|| format!("failed to create secret {}", args.client_secret_name))?;
144+
145+
info!(
146+
namespace = %args.namespace,
147+
server = %args.server_secret_name,
148+
client = %args.client_secret_name,
149+
"PKI secrets created."
150+
);
151+
Ok(())
152+
}
153+
154+
fn tls_secret(name: &str, crt_pem: &str, key_pem: &str, ca_pem: &str) -> Secret {
155+
let mut data = BTreeMap::new();
156+
data.insert(
157+
"tls.crt".to_string(),
158+
ByteString(crt_pem.as_bytes().to_vec()),
159+
);
160+
data.insert(
161+
"tls.key".to_string(),
162+
ByteString(key_pem.as_bytes().to_vec()),
163+
);
164+
data.insert("ca.crt".to_string(), ByteString(ca_pem.as_bytes().to_vec()));
165+
Secret {
166+
metadata: ObjectMeta {
167+
name: Some(name.to_string()),
168+
..Default::default()
169+
},
170+
type_: Some("kubernetes.io/tls".to_string()),
171+
data: Some(data),
172+
..Default::default()
173+
}
174+
}
175+
176+
fn print_bundle(bundle: &PkiBundle) {
177+
println!("# CA certificate\n{}", bundle.ca_cert_pem);
178+
println!("# Server certificate\n{}", bundle.server_cert_pem);
179+
println!("# Server key\n{}", bundle.server_key_pem);
180+
println!("# Client certificate\n{}", bundle.client_cert_pem);
181+
println!("# Client key\n{}", bundle.client_key_pem);
182+
}
183+
184+
#[cfg(test)]
185+
mod tests {
186+
use super::{Action, decide, tls_secret};
187+
188+
#[test]
189+
fn decide_skip_when_both_exist() {
190+
assert_eq!(decide(true, true), Action::SkipExists);
191+
}
192+
193+
#[test]
194+
fn decide_create_when_neither_exists() {
195+
assert_eq!(decide(false, false), Action::Create);
196+
}
197+
198+
#[test]
199+
fn decide_partial_when_only_server_exists() {
200+
assert_eq!(decide(true, false), Action::PartialState);
201+
}
202+
203+
#[test]
204+
fn decide_partial_when_only_client_exists() {
205+
assert_eq!(decide(false, true), Action::PartialState);
206+
}
207+
208+
#[test]
209+
fn tls_secret_has_kubernetes_io_tls_type_and_three_keys() {
210+
let s = tls_secret("foo", "CRT-PEM", "KEY-PEM", "CA-PEM");
211+
assert_eq!(s.metadata.name.as_deref(), Some("foo"));
212+
assert_eq!(s.type_.as_deref(), Some("kubernetes.io/tls"));
213+
let data = s.data.expect("data set");
214+
assert_eq!(data.len(), 3);
215+
assert_eq!(data["tls.crt"].0, b"CRT-PEM");
216+
assert_eq!(data["tls.key"].0, b"KEY-PEM");
217+
assert_eq!(data["ca.crt"].0, b"CA-PEM");
218+
}
219+
}

0 commit comments

Comments
 (0)